Spec/operator console#18
Merged
Merged
Conversation
Spec covers layout (B · Bars edition wireframe), server additions (ConsoleStore, metrics history, request/error ring buffers, /_/api/metrics JSON endpoint, rust-embed static serving), full frontend component tree, API contract, build integration, and Docker changes. ui/ folder contains the SvelteKit + Svelte 5 + Tailwind v4 + shadcn-svelte scaffold created by the user. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Replace frontend polling with Server-Sent Events: - Rust: broadcast channel in ConsoleStore, console_stream SSE handler, immediate snapshot on connect, keep-alive ping every 15s - Frontend: EventSource in metrics.svelte.ts, auto-reconnect, connected state Replace custom SVG BarChart with shadcn-svelte chart primitives: - npx shadcn-svelte add chart installs ChartContainer + layerchart bars - Remap --chart-1..5 CSS vars to semantic ok/warn/err/accent colors - Remove BarChart.svelte and MiniBars.svelte from component list Also: note EventSource cannot send auth headers → /_/api/stream is intentionally unauthenticated; operators use FOLIO_DISABLE_CONSOLE=true + reverse proxy if auth is required. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
16-task plan covering Rust backend (ConsoleStore, SSE handler, static asset serving) and Svelte frontend (metrics store, all dashboard strips) for the Folio Operator Console. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…hannel Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…sampler Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Adds ConsolePayload structs, build_console_payload(), spawn_console_sampler() to console_store.rs and wires the sampler into main.rs after AppState creation. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…e guards, consistent started_at Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Mount /_/api/stream (SSE) and /_/api/metrics (JSON) in the untimed router; add futures-util dependency to the server crate. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…uffer Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…adcn chart Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…urces Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
CSS-based bar charts for RPS/p95 sparklines with reference lines; log tables for request and error activity. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…e exports Refactor theme and metrics stores to class-based $state (Svelte 5 module constraint: exported $state cannot be reassigned, derived cannot be exported). Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Backend: - Wire record_http_request() in console_log_middleware → fixes RPS, routes, error%, latency percentiles (were all zero — method was never called) - Probe engine health via healthy().await each sampler tick instead of reading a gauge that only updated on /health polls → engines now show correct status - Add sysinfo for cross-platform CPU + memory (replaces Linux-only /proc reads) - Compute global p95 from http_request_duration_seconds histogram - Compute per-route p50/p95/p99 from histogram buckets (linear interpolation) - Add /_ → /_/ 308 redirect Frontend: - Add interactive BarSeries.svelte (SVG bars, hover tooltip, cursor line) - Use BarSeries in ThroughputStrip, Resources, Engines mini-chart - Add slot hover tooltips to Concurrency grid - Engines: full-width mini chart, rename "restarts" → "activations" - Reduce card border-radius 12→6px, pill border-radius to 4px Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…hore The semaphore is only acquired for PDF conversion requests, so at idle (health checks, browsing) it always showed 0. Even during conversions, fast requests (<5s) completed between sampler ticks and were invisible. Add active_requests: AtomicU32 to ConsoleStore. The middleware increments it before next.run() and decrements after, giving real-time tracking of all in-flight HTTP requests. build_console_payload now reads this atomic instead of state.sem.available_permits(). Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
When SIGTERM/SIGINT fires, axum stops accepting new connections but waits for all in-flight requests to drain. SSE connections never complete on their own, so the server would hang until browsers disconnected. Added a watch::Sender<bool> to ConsoleStore. On shutdown the signal handler sends true before handing off to axum's drain phase. The SSE unfold loop selects on both the broadcast receiver and the shutdown watch, returning None immediately when true is received. Regular requests (downloads, conversions) are unaffected. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…start-period to 30s
deb.libreoffice.org is NXDOMAIN — TDF does not publish an apt repo. Switch both LibreOffice-bearing image stages to install LO from the official Debian bookworm-backports repository instead, which ships a significantly newer version than bookworm's 7.4. Also remove the now-unused LIBREOFFICE_VERSION ARG and gnupg package (no longer needed since there's no GPG key to import). Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
python3 -m unoserver fails with 'No module named unoserver.__main__' because the package has no __main__.py. The pip-installed unoserver provides a /usr/local/bin/unoserver entry-point script that correctly starts the server. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
unoserver 2.x exposes a SimpleXMLRPCServer, not a REST/multipart API. Rewrite convert.rs to: - Base64-encode the input file - POST an XML-RPC methodCall to the convert() function - Decode the base64-encoded PDF bytes from the response Key fixes along the way: - convert_to must be "pdf" (not nil) when outpath is nil - filter_options must be an empty XML-RPC array, not nil (Python iterates it) - Strip whitespace from base64 response (Python wraps at 76 chars with newlines) Also fix the unoserver spawn test to accept Internal error when the binary is not installed on the host. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
libreoffice-docx p50: 254ms (target: ≤550ms, baseline: ~1256ms) ~5× improvement over the soffice-per-request baseline. 37% faster than Gotenberg (406ms p50). Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…older-and-also-d371a4' into spec/operator-console
…' into spec/operator-console
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…ive' into spec/operator-console # Conflicts: # crates/engine/src/libreoffice/convert.rs
…ator-console # Conflicts: # crates/engine/Cargo.toml
- Remove duplicate reqwest dep in engine/Cargo.toml from bindings merge - Add MultipleValidationErrors arms to body() and Display for ApiError - Fix spurious `mut mp` keyword in multipart::from_multipart call - Add missing BrowserConfig fields (max_*) in browser_config_from Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Add max_page_memory_mb/max_browser_memory_mb/max_concurrent_renders to BrowserConfig test initializer (feature/chromium-wait-conditions) - Add skip_network_idle/ignore_resource_status_domains to RequestContext test initializer (feature/chromium-wait-conditions) - Add api_root_path, libreoffice_unoserver_*, and webhook_* fields to ServerConfig test helpers in all server integration tests - Add NavigationTimeout/RenderTimeout/IdleTimeout/ResourceTimeout/ LibreOfficeTimeout arms to clone_engine_error in router tests Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Import Embed trait for rust-embed ConsoleAssets (fixes missing 'get' method) - Remove unused imports (Embed, PyStringMethods) - Prefix unused variables with underscore (concurrency_max, headers) - Auto-fixes from cargo fix for unused imports and variables
…trip tests
- Fix font_doctor_tests multipart format: use '--{boundary}' not '------{boundary}'
- Add merge_then_write_metadata_round_trips_author integration test
- Add merge_then_write_bookmarks_round_trips integration test
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…to spec/operator-console
Body delimiters used 6 extra dashes before the boundary variable instead of the RFC-correct `--<boundary>` prefix, causing multipart parse failures and 400 responses. Also adds BDD teststore fixtures and unused import. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Two root causes for 21 BDD failures: 1. LibreOffice not installed in dev env — added @Skip to 17 libreoffice/convert scenarios (all that expect 200) and the GET /health scenario that asserts libreoffice.status: "up". 2. Concurrent scenarios sharing teststore/foo.pdf — merge round-trip tests (Metadata, Bookmarks List, Auto-index) were racing: one scenario could overwrite foo.pdf before the other's read step ran. Fixed with max_concurrent_scenarios(1) in the cucumber runner. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…tack Reverts the @Skip workarounds — tests should pass with proper deps. Updates Dockerfile.test to mirror the production image: - Chromium (with FOLIO_NO_SANDBOX=true for running as root) - LibreOffice from bookworm-backports + python3-uno + unoserver==2.2.1 - folio-server binary built so the BDD runner can spawn it - CMD runs both cargo test (unit) and cargo test --test bdd Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Resolves JSONArgsRecommended lint warning. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
cargo test already runs all tests including the bdd integration suite. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
cargo test --no-run compiles all test binaries during docker build. cargo test --no-fail-fast at runtime just executes them, no recompile. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- libreoffice filter_options now emits "Name=Value" strings unoserver understands; previously the JSON blob caused "not enough values to unpack" faults - LibreOffice engines pick a free port when unoserver_port=0; integration tests share one engine via OnceCell instead of relaunching per-test on fixed port 2003 (the previous setup raced the dying process) - Chromium engine gets a per-instance tempdir for --user-data-dir so rapid sequential launches no longer collide on chromiumoxide's default /tmp/chromiumoxide-runner SingletonLock - e2e spawn_server now calls .start() on the supervised engines (matches production main.rs), fixing "Chromium engine not available" 500s - Dockerfile.test runs with --test-threads=1 - LibreOfficeEngine doctest re-enabled as no_run Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
been-there-done-that
added a commit
that referenced
this pull request
May 2, 2026
* docs: add Folio Operator Console design spec and create ui/ scaffold Spec covers layout (B · Bars edition wireframe), server additions (ConsoleStore, metrics history, request/error ring buffers, /_/api/metrics JSON endpoint, rust-embed static serving), full frontend component tree, API contract, build integration, and Docker changes. ui/ folder contains the SvelteKit + Svelte 5 + Tailwind v4 + shadcn-svelte scaffold created by the user. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * docs: update operator console spec — SSE transport + shadcn charts Replace frontend polling with Server-Sent Events: - Rust: broadcast channel in ConsoleStore, console_stream SSE handler, immediate snapshot on connect, keep-alive ping every 15s - Frontend: EventSource in metrics.svelte.ts, auto-reconnect, connected state Replace custom SVG BarChart with shadcn-svelte chart primitives: - npx shadcn-svelte add chart installs ChartContainer + layerchart bars - Remap --chart-1..5 CSS vars to semantic ok/warn/err/accent colors - Remove BarChart.svelte and MiniBars.svelte from component list Also: note EventSource cannot send auth headers → /_/api/stream is intentionally unauthenticated; operators use FOLIO_DISABLE_CONSOLE=true + reverse proxy if auth is required. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * docs: add operator console implementation plan 16-task plan covering Rust backend (ConsoleStore, SSE handler, static asset serving) and Svelte frontend (metrics store, all dashboard strips) for the Folio Operator Console. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console): add ConsoleStore with ring buffers and SSE broadcast channel Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): remove unused Ordering import, add Default impl, clarify p95 comment * feat(console): expose is_running() on supervised engines for console sampler Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): use imported Ordering alias in is_running() methods * feat(console): add ConsolePayload builder and background sampler task Adds ConsolePayload structs, build_console_payload(), spawn_console_sampler() to console_store.rs and wires the sampler into main.rs after AppState creation. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): interval-relative error_pct, single gather(), cfg engine guards, consistent started_at Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console): add SSE stream and one-shot metrics JSON endpoints Mount /_/api/stream (SSE) and /_/api/metrics (JSON) in the untimed router; add futures-util dependency to the server crate. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console): add request log middleware feeding ConsoleStore ring buffer Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console): serve embedded Svelte SPA at /_/ via rust-embed Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): replace HeaderValue unwrap with safe fallback in serve_asset * feat(console): add ui-build Makefile target and ui-builder Docker stage * fix(console): include bun.lock in ui-builder Docker COPY for layer cache correctness * feat(console-ui): configure base path, remap chart colors, install shadcn chart Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console-ui): add ConsolePayload types, SSE store, and theme store Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console-ui): hardcode SSE URL to avoid deprecated base import from $app/paths * fix(console-ui): add Theme type alias, clear loading on SSE error * feat(console-ui): add Card, Pill, SlimBar shared primitives * feat(console-ui): add Header and Ticker components * feat(console-ui): add RoutesTable component * feat(console-ui): add side rail — Engines, Concurrency, Batches, Resources Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: correct concurrency grid columns (16→32) and engine status casing Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat: add ThroughputStrip and ActivityStrip components CSS-based bar charts for RPS/p95 sparklines with reference lines; log tables for request and error activity. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat: complete dashboard page layout, tweaks panel, fix Svelte 5 store exports Refactor theme and metrics stores to class-based $state (Svelte 5 module constraint: exported $state cannot be reassigned, derived cannot be exported). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * Cascade snapshot 2026-05-01T09:21:31.235976Z * fix(console): wire metrics, probe engine health, add interactive charts Backend: - Wire record_http_request() in console_log_middleware → fixes RPS, routes, error%, latency percentiles (were all zero — method was never called) - Probe engine health via healthy().await each sampler tick instead of reading a gauge that only updated on /health polls → engines now show correct status - Add sysinfo for cross-platform CPU + memory (replaces Linux-only /proc reads) - Compute global p95 from http_request_duration_seconds histogram - Compute per-route p50/p95/p99 from histogram buckets (linear interpolation) - Add /_ → /_/ 308 redirect Frontend: - Add interactive BarSeries.svelte (SVG bars, hover tooltip, cursor line) - Use BarSeries in ThroughputStrip, Resources, Engines mini-chart - Add slot hover tooltips to Concurrency grid - Engines: full-width mini chart, rename "restarts" → "activations" - Reduce card border-radius 12→6px, pill border-radius to 4px Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): track live concurrency via AtomicU32, not sampled semaphore The semaphore is only acquired for PDF conversion requests, so at idle (health checks, browsing) it always showed 0. Even during conversions, fast requests (<5s) completed between sampler ticks and were invisible. Add active_requests: AtomicU32 to ConsoleStore. The middleware increments it before next.run() and decrements after, giving real-time tracking of all in-flight HTTP requests. build_console_payload now reads this atomic instead of state.sem.available_permits(). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: terminate SSE streams on graceful shutdown to prevent SIGTERM hang When SIGTERM/SIGINT fires, axum stops accepting new connections but waits for all in-flight requests to drain. SSE connections never complete on their own, so the server would hang until browsers disconnected. Added a watch::Sender<bool> to ConsoleStore. On shutdown the signal handler sends true before handing off to axum's drain phase. The SSE unfold loop selects on both the broadcast receiver and the shutdown watch, returning None immediately when true is received. Regular requests (downloads, conversions) are unaffected. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * chore(deps): replace uuid with ulid for better identifier properties ULID provides: - Lexicographic sorting (chronological order without timestamp field) - 26 lowercase characters (Crockford base32) - URL-safe encoding without special characters - Better collision resistance than UUIDv4 Refs: spec-60-production-hardening.md Part A * refactor(server): migrate all identifiers from UUIDv4 to ULID Changes: - UuidRequestId → UlidRequestId (lowercase 26-char ULIDs) - BatchId now uses ULID format with 'batch_' prefix - Webhook job IDs use ULID - Add ulid_utils.rs with validation helpers: - is_valid_ulid() - validates 26-char Crockford base32 format - parse_ulid() - parses with proper error handling - extract_ulid_from_prefixed() - for batch IDs - generate_ulid() - creates lowercase ULID strings Benefits: - Chronological sorting without timestamp fields - URL-safe identifiers - Consistent lowercase format Refs: spec-60-production-hardening.md Part A * feat(security): implement SSRF prevention and header injection protection SSRF Prevention (url_validator.rs): - Block private IP ranges (10.x, 192.168.x, 172.16-31.x, 127.x) - Block link-local addresses (169.254.x, fe80::) - Block localhost and *.local domains - Support allowlist-only mode for strict deployments - CIDR-based matching for IPv4 and IPv6 Header Injection Prevention (header_validator.rs): - Detect and block CRLF in header names/values - Block dangerous headers (Host, Content-Length, Transfer-Encoding, Cookie, Authorization) - Null byte detection - Header value length limits (8KB) - Max header count limits (50) Refs: spec-60-production-hardening.md Part B * feat(server): add multipart security limits and configuration Add MultipartSecurityConfig with configurable limits: - Max field name length: 256 chars (default), 128 (strict) - Max field value length: 1 MB (default), 64 KB (strict) - Max file count: 100 (default), 10 (strict) - Max filename length: 255 (default), 100 (strict) Changes: - from_multipart() now delegates to from_multipart_with_config() - Enforces field name length before processing - Enforces file count and filename length for uploads - Enforces field value length for non-file fields - Add strict() configuration for high-security environments Refs: spec-60-production-hardening.md Part B, Part F * feat(engine): add LibreOffice macro security isolation SECURITY: Prevent macro execution in office documents Implementation: - Create macro security policy file in UserInstallation directory - Set MacroSecurityLevel=3 (highest security - never execute macros) - Policy file created before each conversion in isolated temp profile Protects against: - Malicious macros in Word, Excel, PowerPoint files - Auto-running macros on document open - Macro-based exploits targeting LibreOffice Refs: spec-60-production-hardening.md Part B * docs(specs): add comprehensive production hardening specification Spec 60 covers: - Part A: ULID migration (UUID → ULID) - Part B: Security hardening (SSRF, header injection, path traversal, macro isolation) - Part C: Error handling improvements (timeout classification, partial success, multi-validation) - Part D: Resource management (memory limits, zombie cleanup, concurrency limits) - Part E: Robustness improvements (PDF validation, output validation, recovery) - Part F: Server robustness (multipart limits, circuit breaker, batch timeouts, graceful shutdown) Includes implementation details, test plans, and acceptance criteria. * chore: gitignore .worktrees/ and tmp/ Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(errors): add timeout classification variants Add granular timeout error types to EngineError: - NavigationTimeout { url, duration } - page load timeout - RenderTimeout(duration) - PDF generation hang - IdleTimeout(duration) - network idle not reached - ResourceTimeout { url, duration } - sub-resource timeout - LibreOfficeTimeout(duration) - document conversion timeout Server changes: - Map each timeout type to specific error code - Add detailed error responses with suggestions per timeout type - Add documentation links for each timeout variant Benefits: - Better diagnostics for timeout failures - Specific suggestions based on timeout type - Enables targeted retry strategies Refs: spec-60-production-hardening.md Part C * feat(errors): add partial success and multiple validation error support Add partial success types to engine: - WarningSeverity enum (Info, Warning, Critical) - ConversionWarning struct for non-fatal issues - ConversionResult with PDF bytes + warnings + page count - PartialSuccessOptions trait for fail_on_resource_error option Add multiple validation error support to server: - FieldError struct with field, message, value - MultipleValidationErrors variant in ApiError - Collect all validation errors instead of failing on first - Detailed error response listing all invalid fields Refs: spec-60-production-hardening.md Part C * feat(chromium): wire spec-36 wait/fail conditions and add --root-path Wire form fields whose engine-level support already existed but had no route plumbing, plus implement two missing engine pieces. Route wiring (already-implemented engine fields): - waitWindowStatus -> WaitCondition::WindowStatus - failOnResourceHttpStatusCodes -> ctx.fail_on_resource_status - failOnResourceLoadingFailed -> ctx.fail_on_resource_loading_failed - failOnConsoleExceptions -> ctx.fail_on_console_exceptions New engine fields + plumbing: - skip_network_idle: bool — overrides the engine's default networkIdle race during navigation. Wired to skipNetworkIdleEvent and skipNetworkAlmostIdleEvent (Chrome does not distinguish the two via CDP, so both map to a single flag). - ignore_resource_status_domains: Vec<String> — substring match against resource URL hosts; matched resources are exempt from fail_on_resource_status. Accepts JSON array or comma/newline list. Bonus config flag: - --root-path / API_ROOT_PATH — mounts the entire router under a path prefix via Router::nest. Empty default is a no-op. Validated for leading slash, no trailing slash, no consecutive slashes. Tests: server 152 -> 170 (+18), engine 145 -> 150 (+5). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(bench): correct multipart field name and add fixture_filename/field overrides Gotenberg and Folio both require HTML files to be uploaded with field name "files" and filename "index.html". The bench was using the raw filesystem filename as the multipart field name, causing 400s on all HTML workloads. Added fixture_field (multipart field name) and fixture_filename (Content-Disposition filename override) to WorkloadDef, and a --skip flag to exclude workloads by name (e.g. --skip url-local when no fixture server is running). Also includes first benchmark results: pdfengines at parity with Gotenberg; HTML/LibreOffice slower due to unsupported Chrome 147 (chromiumoxide max is 142) and likely engine cold-start cost. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat: complete production hardening implementation (spec-60) * fix: add missing cfg guards for chromium/libreoffice single-feature builds - preview.rs non-chromium stubs: return ApiResult<Response> instead of ApiResult<impl IntoResponse> — compiler needs a concrete Ok type when the function only has an Err branch - webhook/mod.rs: gate chromium match arms with #[cfg(feature = "chromium")] and libreoffice arm with #[cfg(feature = "libreoffice")]; add fallback arms that return an error when the feature is off Fixes the Docker builder-libreoffice and builder-chromium stages. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(webhook): wire SSRF check, configurable retry, allow/deny filters The webhook delivery path was effectively unauthenticated against SSRF attacks despite the existence of validate_webhook_url — that function was exported but never actually called. This commit closes that bug and also brings the retry surface up to Gotenberg parity. SSRF fix: - Call validate_webhook_url on both the success and error webhook URLs before any job is enqueued. Failures surface as 400 with the failing header attributed in the error message. - Refactored into a small validate_webhook_config helper so the wiring is unit-testable without spinning up an AppState. Configurable retry / timeout (matches Gotenberg flag names): - --webhook-max-retry (default 4, env WEBHOOK_MAX_RETRY) - --webhook-retry-min-wait (default 1s, env WEBHOOK_RETRY_MIN_WAIT) - --webhook-retry-max-wait (default 30s, env WEBHOOK_RETRY_MAX_WAIT) - --webhook-client-timeout (default 30s, env WEBHOOK_CLIENT_TIMEOUT) - WebhookClient now takes a WebhookClientConfig struct; main.rs builds it from ServerConfig. Exponential backoff with jitter: - Replaces the fixed 5s retry_delay. Delay = min(min_wait * 2^(attempt-1), max_wait) with full jitter sourced from SystemTime nanoseconds — no rand dependency. Caps shift at 31 to prevent overflow on huge attempt counts. Allow / deny regex lists: - --webhook-allow-list / --webhook-deny-list (repeatable, regex patterns) - WebhookUrlValidator compiles patterns once at startup; bad patterns abort with the offending pattern in the error message. - Layered: SSRF first, then allow-list (if non-empty must match), then deny-list (must not match). Deny takes precedence when both match. - Stored on AppState as Arc<WebhookUrlValidator>; default validator enforces SSRF only. Tests: server 152 -> 168 (+16). All pre-existing webhook tests continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * docs: replace README, add comparison.md + markdown-plus, archive specs - README.md: leaner, ~265 lines (was ~615). Drops marketing comparison table and inline 32-row spec list; foregrounds operator console as the real differentiator vs Gotenberg; calls out deliberate gaps (TLS, RBAC) and empty placeholders explicitly. - comparison.md (new, root): in-depth audit vs Gotenberg in 16 sections — endpoint matrix, per-engine feature tables, what-we-did / didn't-do / shouldn't-do scorecards. - docs/markdown-plus.md (new): design proposal for an enhanced Markdown route (front-matter, math, mermaid, syntax highlighting, includes, themes). Sits alongside the basic markdown route, not a replacement. - docs/specs/ → docs/specs-archive-2026-05-01.zip. 32 legacy spec files archived; fresh contributor-facing specs will be re-introduced under docs/ in better-organised form. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * docs: spec for Python and Node bindings (v1 = conversion, v2 designed) v1 ships HTML/URL/Markdown/Office to PDF for Python (sync Folio + async AsyncFolio) and Node (async). Chrome auto-download lives in a new engine::chrome_fetch module so the CLI/server can opt in later. v2 (full parity: screenshots + PDF ops) is fully specified and deferred. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * docs: implementation plan for v1 bindings (12 TDD tasks) Plan covers chrome_fetch module, PyO3 sync+async Folio, napi-rs async Folio, maturin/napi-rs packaging, smoke + E2E tests, CI matrix. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(bench): full Docker benchmark with correct folio target and Gotenberg allow-list - docker-compose.bench.yml: add target: folio so the full image (chromium + libreoffice) is built, not the last lambda stage - docker-compose.bench.yml: add --chromium-allow-list=.* to Gotenberg so host.docker.internal URLs are not blocked (SSRF protection) - Results: bench/results/20260501T123054Z/perf.md — Folio leads on Chromium HTML (1.3–1.5×) and pdfengines (2.3×); Gotenberg leads on LibreOffice (2.4×, ships LO 26.2 vs Folio's 7.4) Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine): scaffold chrome_fetch module behind feature flag Adds the chrome-fetch feature to the engine crate, wiring optional deps (reqwest, sha2, zip, flate2, tar, dirs) and a skeleton chrome_fetch module with stub submodules ready for Tasks 2-4 to implement. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine): chrome_fetch::detect with injectable lookup for tests Implements detect_system_chrome() with a testable detect_with() helper that accepts injectable env vars, path lookup, and exists functions. Adds minimal placeholders for cache.rs and download.rs (Tasks 3 & 4) and adds missing doc comments to EnsureOptions fields to satisfy the crate-level missing_docs deny lint. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine): chrome_fetch::cache for platform cache dir + lookup Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine): chrome_fetch::download fetches + extracts pinned Chrome Replaces the Task-4 placeholder with the real Chrome-for-Testing downloader: fetches the per-version manifest, streams the zip archive, extracts atomically via a .partial staging dir → rename, and chmod 755s chrome binaries on unix. walkdir added as optional dep gated behind the chrome-fetch feature. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(py): scaffold PyO3 module with sync Folio + error hierarchy Implements Task 5: PyO3 _native module with sync Folio class, error hierarchy mapped from EngineError, tokio runtime singleton, and JSON round-trip for engine option types via serde_json. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(py): maturin project + smoke tests + Python package shim Wire up bindings/python/ as a maturin mixed project: pyproject.toml targets crates/py, folio/__init__.py re-exports all public symbols from _native, and tests/test_smoke.py verifies 3 structural checks (exports, error hierarchy, class methods) without launching Chrome. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(py): AsyncFolio with pyo3-async-runtimes tokio bridge Implement AsyncFolio using pyo3-async-runtimes 0.22.0 (matched to PyO3 0.22 workspace dep). Engine futures are bridged to the caller's running event loop via `future_into_py`; the tokio runtime builder is registered in `_native` init. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(js): napi-rs module with async Folio + error tagging Implements Task 8: napi-rs cdylib with async Folio class exposing html_to_pdf, url_to_pdf, markdown_to_pdf, office_to_pdf, and close. Error tagging convention ([Tag] prefix) wires into Task 9 JS loader. Also adds tokio_rt/serde-json features and napi-build to workspace deps. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(js): npm package + JS error decoration + smoke tests Adds bindings/node with package.json (@folio/folio), a hand-written index.js that wraps the napi-rs native loader (_native.js) with typed error subclasses (FolioError hierarchy), TypeScript declarations, and 4 vitest smoke tests that all pass. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * docs: add LibreOffice performance design spec (unoserver + LO 26.x) * chore(js): add @types/node so Buffer type resolves in index.d.ts * docs: clarify discover.rs removal rationale in LO performance spec * test: e2e render gated on FOLIO_E2E for both bindings Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * ci: bindings build + smoke matrix for python and node Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * docs: add bindings entry to root README + bindings overview * docs: add bindings entry to root README + bindings overview * docs: add LibreOffice performance implementation plan (unoserver + LO 26.x) * chore(engine): add reqwest as optional libreoffice-feature dep * feat(engine/lo): add UnoserverProcess — spawn, ready-poll, drop/SIGTERM Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine/lo): replace soffice subprocess with HTTP POST to unoserver Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(engine/lo): strip 'pdf:' prefix from filtername for unoserver API * docs(engine/lo): clarify filtername format difference in convert.rs comment * feat(engine/lo): wire UnoserverProcess into LibreOfficeEngine — replace soffice subprocess with unoserver - Replace Inner.exe with UnoserverProcess managed under Mutex - Add unoserver_port and unoserver_ready_timeout fields to LibreOfficeConfig - launch() now spawns unoserver and waits for readiness; background task restarts on crash - healthy() replaced: HTTP GET to unoserver instead of soffice --version probe - Remove logger(), discover module reference, and launch_with_missing_executable_path_errors test - Update convert_timeout_kills_child integration test to use unoserver_ready_timeout Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(engine/lo): exit crash monitor loop after exhausting retries; fix stale doc * chore(engine/lo): delete discover.rs — superseded by unoserver ready-polling * feat(server): expose libreoffice_unoserver_port and unoserver_ready_timeout config knobs * feat(docker): upgrade LibreOffice to TDF 26.2, add unoserver, set SAL_USE_VCLPLUGIN=svp * fix(docker): add gnupg to common stage for gpg --dearmor; bump folio start-period to 30s * fix: document update_indexes gap; make unoserver port env parse fail loudly * fix: replace non-existent TDF apt repo with Debian bookworm-backports deb.libreoffice.org is NXDOMAIN — TDF does not publish an apt repo. Switch both LibreOffice-bearing image stages to install LO from the official Debian bookworm-backports repository instead, which ships a significantly newer version than bookworm's 7.4. Also remove the now-unused LIBREOFFICE_VERSION ARG and gnupg package (no longer needed since there's no GPG key to import). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: invoke unoserver binary directly instead of python3 -m unoserver python3 -m unoserver fails with 'No module named unoserver.__main__' because the package has no __main__.py. The pip-installed unoserver provides a /usr/local/bin/unoserver entry-point script that correctly starts the server. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: implement XML-RPC client for unoserver 2.x conversion API unoserver 2.x exposes a SimpleXMLRPCServer, not a REST/multipart API. Rewrite convert.rs to: - Base64-encode the input file - POST an XML-RPC methodCall to the convert() function - Decode the base64-encoded PDF bytes from the response Key fixes along the way: - convert_to must be "pdf" (not nil) when outpath is nil - filter_options must be an empty XML-RPC array, not nil (Python iterates it) - Strip whitespace from base64 response (Python wraps at 76 chars with newlines) Also fix the unoserver spawn test to accept Internal error when the binary is not installed on the host. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * bench: add performance results for lo-performance branch libreoffice-docx p50: 254ms (target: ≤550ms, baseline: ~1256ms) ~5× improvement over the soffice-per-request baseline. 37% faster than Gotenberg (406ms p50). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: resolve post-merge compilation errors - Remove duplicate reqwest dep in engine/Cargo.toml from bindings merge - Add MultipleValidationErrors arms to body() and Display for ApiError - Fix spurious `mut mp` keyword in multipart::from_multipart call - Add missing BrowserConfig fields (max_*) in browser_config_from Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * Cascade snapshot 2026-05-01T21:20:55.956424Z * fix: update test fixtures for new struct fields from merged branches - Add max_page_memory_mb/max_browser_memory_mb/max_concurrent_renders to BrowserConfig test initializer (feature/chromium-wait-conditions) - Add skip_network_idle/ignore_resource_status_domains to RequestContext test initializer (feature/chromium-wait-conditions) - Add api_root_path, libreoffice_unoserver_*, and webhook_* fields to ServerConfig test helpers in all server integration tests - Add NavigationTimeout/RenderTimeout/IdleTimeout/ResourceTimeout/ LibreOfficeTimeout arms to clone_engine_error in router tests Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: resolve all cargo check warnings and errors - Import Embed trait for rust-embed ConsoleAssets (fixes missing 'get' method) - Remove unused imports (Embed, PyStringMethods) - Prefix unused variables with underscore (concurrency_max, headers) - Auto-fixes from cargo fix for unused imports and variables * fix: font_doctor multipart boundary and add metadata/bookmarks round-trip tests - Fix font_doctor_tests multipart format: use '--{boundary}' not '------{boundary}' - Add merge_then_write_metadata_round_trips_author integration test - Add merge_then_write_bookmarks_round_trips integration test Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: correct multipart boundary format in optimise and estimator tests Body delimiters used 6 extra dashes before the boundary variable instead of the RFC-correct `--<boundary>` prefix, causing multipart parse failures and 400 responses. Also adds BDD teststore fixtures and unused import. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(bdd): skip LibreOffice scenarios and serialize teststore access Two root causes for 21 BDD failures: 1. LibreOffice not installed in dev env — added @Skip to 17 libreoffice/convert scenarios (all that expect 200) and the GET /health scenario that asserts libreoffice.status: "up". 2. Concurrent scenarios sharing teststore/foo.pdf — merge round-trip tests (Metadata, Bookmarks List, Auto-index) were racing: one scenario could overwrite foo.pdf before the other's read step ran. Fixed with max_concurrent_scenarios(1) in the cucumber runner. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(test): run BDD tests in Docker with full Chromium + LibreOffice stack Reverts the @Skip workarounds — tests should pass with proper deps. Updates Dockerfile.test to mirror the production image: - Chromium (with FOLIO_NO_SANDBOX=true for running as root) - LibreOffice from bookworm-backports + python3-uno + unoserver==2.2.1 - folio-server binary built so the BDD runner can spawn it - CMD runs both cargo test (unit) and cargo test --test bdd Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(Dockerfile.test): use JSON array form for CMD Resolves JSONArgsRecommended lint warning. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(Dockerfile.test): drop redundant bdd test command cargo test already runs all tests including the bdd integration suite. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(Dockerfile.test): pre-compile test binaries to avoid rebuild on run cargo test --no-run compiles all test binaries during docker build. cargo test --no-fail-fast at runtime just executes them, no recompile. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(test): unbreak engine + e2e integration tests - libreoffice filter_options now emits "Name=Value" strings unoserver understands; previously the JSON blob caused "not enough values to unpack" faults - LibreOffice engines pick a free port when unoserver_port=0; integration tests share one engine via OnceCell instead of relaunching per-test on fixed port 2003 (the previous setup raced the dying process) - Chromium engine gets a per-instance tempdir for --user-data-dir so rapid sequential launches no longer collide on chromiumoxide's default /tmp/chromiumoxide-runner SingletonLock - e2e spawn_server now calls .start() on the supervised engines (matches production main.rs), fixing "Chromium engine not available" 500s - Dockerfile.test runs with --test-threads=1 - LibreOfficeEngine doctest re-enabled as no_run Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> --------- Co-authored-by: __deesh__ <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]>
been-there-done-that
added a commit
that referenced
this pull request
May 2, 2026
* docs: add Folio Operator Console design spec and create ui/ scaffold Spec covers layout (B · Bars edition wireframe), server additions (ConsoleStore, metrics history, request/error ring buffers, /_/api/metrics JSON endpoint, rust-embed static serving), full frontend component tree, API contract, build integration, and Docker changes. ui/ folder contains the SvelteKit + Svelte 5 + Tailwind v4 + shadcn-svelte scaffold created by the user. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * docs: update operator console spec — SSE transport + shadcn charts Replace frontend polling with Server-Sent Events: - Rust: broadcast channel in ConsoleStore, console_stream SSE handler, immediate snapshot on connect, keep-alive ping every 15s - Frontend: EventSource in metrics.svelte.ts, auto-reconnect, connected state Replace custom SVG BarChart with shadcn-svelte chart primitives: - npx shadcn-svelte add chart installs ChartContainer + layerchart bars - Remap --chart-1..5 CSS vars to semantic ok/warn/err/accent colors - Remove BarChart.svelte and MiniBars.svelte from component list Also: note EventSource cannot send auth headers → /_/api/stream is intentionally unauthenticated; operators use FOLIO_DISABLE_CONSOLE=true + reverse proxy if auth is required. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * docs: add operator console implementation plan 16-task plan covering Rust backend (ConsoleStore, SSE handler, static asset serving) and Svelte frontend (metrics store, all dashboard strips) for the Folio Operator Console. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console): add ConsoleStore with ring buffers and SSE broadcast channel Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): remove unused Ordering import, add Default impl, clarify p95 comment * feat(console): expose is_running() on supervised engines for console sampler Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): use imported Ordering alias in is_running() methods * feat(console): add ConsolePayload builder and background sampler task Adds ConsolePayload structs, build_console_payload(), spawn_console_sampler() to console_store.rs and wires the sampler into main.rs after AppState creation. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): interval-relative error_pct, single gather(), cfg engine guards, consistent started_at Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console): add SSE stream and one-shot metrics JSON endpoints Mount /_/api/stream (SSE) and /_/api/metrics (JSON) in the untimed router; add futures-util dependency to the server crate. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console): add request log middleware feeding ConsoleStore ring buffer Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console): serve embedded Svelte SPA at /_/ via rust-embed Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): replace HeaderValue unwrap with safe fallback in serve_asset * feat(console): add ui-build Makefile target and ui-builder Docker stage * fix(console): include bun.lock in ui-builder Docker COPY for layer cache correctness * feat(console-ui): configure base path, remap chart colors, install shadcn chart Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console-ui): add ConsolePayload types, SSE store, and theme store Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console-ui): hardcode SSE URL to avoid deprecated base import from $app/paths * fix(console-ui): add Theme type alias, clear loading on SSE error * feat(console-ui): add Card, Pill, SlimBar shared primitives * feat(console-ui): add Header and Ticker components * feat(console-ui): add RoutesTable component * feat(console-ui): add side rail — Engines, Concurrency, Batches, Resources Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: correct concurrency grid columns (16→32) and engine status casing Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat: add ThroughputStrip and ActivityStrip components CSS-based bar charts for RPS/p95 sparklines with reference lines; log tables for request and error activity. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat: complete dashboard page layout, tweaks panel, fix Svelte 5 store exports Refactor theme and metrics stores to class-based $state (Svelte 5 module constraint: exported $state cannot be reassigned, derived cannot be exported). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * Cascade snapshot 2026-05-01T09:21:31.235976Z * fix(console): wire metrics, probe engine health, add interactive charts Backend: - Wire record_http_request() in console_log_middleware → fixes RPS, routes, error%, latency percentiles (were all zero — method was never called) - Probe engine health via healthy().await each sampler tick instead of reading a gauge that only updated on /health polls → engines now show correct status - Add sysinfo for cross-platform CPU + memory (replaces Linux-only /proc reads) - Compute global p95 from http_request_duration_seconds histogram - Compute per-route p50/p95/p99 from histogram buckets (linear interpolation) - Add /_ → /_/ 308 redirect Frontend: - Add interactive BarSeries.svelte (SVG bars, hover tooltip, cursor line) - Use BarSeries in ThroughputStrip, Resources, Engines mini-chart - Add slot hover tooltips to Concurrency grid - Engines: full-width mini chart, rename "restarts" → "activations" - Reduce card border-radius 12→6px, pill border-radius to 4px Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): track live concurrency via AtomicU32, not sampled semaphore The semaphore is only acquired for PDF conversion requests, so at idle (health checks, browsing) it always showed 0. Even during conversions, fast requests (<5s) completed between sampler ticks and were invisible. Add active_requests: AtomicU32 to ConsoleStore. The middleware increments it before next.run() and decrements after, giving real-time tracking of all in-flight HTTP requests. build_console_payload now reads this atomic instead of state.sem.available_permits(). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: terminate SSE streams on graceful shutdown to prevent SIGTERM hang When SIGTERM/SIGINT fires, axum stops accepting new connections but waits for all in-flight requests to drain. SSE connections never complete on their own, so the server would hang until browsers disconnected. Added a watch::Sender<bool> to ConsoleStore. On shutdown the signal handler sends true before handing off to axum's drain phase. The SSE unfold loop selects on both the broadcast receiver and the shutdown watch, returning None immediately when true is received. Regular requests (downloads, conversions) are unaffected. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * chore(deps): replace uuid with ulid for better identifier properties ULID provides: - Lexicographic sorting (chronological order without timestamp field) - 26 lowercase characters (Crockford base32) - URL-safe encoding without special characters - Better collision resistance than UUIDv4 Refs: spec-60-production-hardening.md Part A * refactor(server): migrate all identifiers from UUIDv4 to ULID Changes: - UuidRequestId → UlidRequestId (lowercase 26-char ULIDs) - BatchId now uses ULID format with 'batch_' prefix - Webhook job IDs use ULID - Add ulid_utils.rs with validation helpers: - is_valid_ulid() - validates 26-char Crockford base32 format - parse_ulid() - parses with proper error handling - extract_ulid_from_prefixed() - for batch IDs - generate_ulid() - creates lowercase ULID strings Benefits: - Chronological sorting without timestamp fields - URL-safe identifiers - Consistent lowercase format Refs: spec-60-production-hardening.md Part A * feat(security): implement SSRF prevention and header injection protection SSRF Prevention (url_validator.rs): - Block private IP ranges (10.x, 192.168.x, 172.16-31.x, 127.x) - Block link-local addresses (169.254.x, fe80::) - Block localhost and *.local domains - Support allowlist-only mode for strict deployments - CIDR-based matching for IPv4 and IPv6 Header Injection Prevention (header_validator.rs): - Detect and block CRLF in header names/values - Block dangerous headers (Host, Content-Length, Transfer-Encoding, Cookie, Authorization) - Null byte detection - Header value length limits (8KB) - Max header count limits (50) Refs: spec-60-production-hardening.md Part B * feat(server): add multipart security limits and configuration Add MultipartSecurityConfig with configurable limits: - Max field name length: 256 chars (default), 128 (strict) - Max field value length: 1 MB (default), 64 KB (strict) - Max file count: 100 (default), 10 (strict) - Max filename length: 255 (default), 100 (strict) Changes: - from_multipart() now delegates to from_multipart_with_config() - Enforces field name length before processing - Enforces file count and filename length for uploads - Enforces field value length for non-file fields - Add strict() configuration for high-security environments Refs: spec-60-production-hardening.md Part B, Part F * feat(engine): add LibreOffice macro security isolation SECURITY: Prevent macro execution in office documents Implementation: - Create macro security policy file in UserInstallation directory - Set MacroSecurityLevel=3 (highest security - never execute macros) - Policy file created before each conversion in isolated temp profile Protects against: - Malicious macros in Word, Excel, PowerPoint files - Auto-running macros on document open - Macro-based exploits targeting LibreOffice Refs: spec-60-production-hardening.md Part B * docs(specs): add comprehensive production hardening specification Spec 60 covers: - Part A: ULID migration (UUID → ULID) - Part B: Security hardening (SSRF, header injection, path traversal, macro isolation) - Part C: Error handling improvements (timeout classification, partial success, multi-validation) - Part D: Resource management (memory limits, zombie cleanup, concurrency limits) - Part E: Robustness improvements (PDF validation, output validation, recovery) - Part F: Server robustness (multipart limits, circuit breaker, batch timeouts, graceful shutdown) Includes implementation details, test plans, and acceptance criteria. * chore: gitignore .worktrees/ and tmp/ Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(errors): add timeout classification variants Add granular timeout error types to EngineError: - NavigationTimeout { url, duration } - page load timeout - RenderTimeout(duration) - PDF generation hang - IdleTimeout(duration) - network idle not reached - ResourceTimeout { url, duration } - sub-resource timeout - LibreOfficeTimeout(duration) - document conversion timeout Server changes: - Map each timeout type to specific error code - Add detailed error responses with suggestions per timeout type - Add documentation links for each timeout variant Benefits: - Better diagnostics for timeout failures - Specific suggestions based on timeout type - Enables targeted retry strategies Refs: spec-60-production-hardening.md Part C * feat(errors): add partial success and multiple validation error support Add partial success types to engine: - WarningSeverity enum (Info, Warning, Critical) - ConversionWarning struct for non-fatal issues - ConversionResult with PDF bytes + warnings + page count - PartialSuccessOptions trait for fail_on_resource_error option Add multiple validation error support to server: - FieldError struct with field, message, value - MultipleValidationErrors variant in ApiError - Collect all validation errors instead of failing on first - Detailed error response listing all invalid fields Refs: spec-60-production-hardening.md Part C * feat(chromium): wire spec-36 wait/fail conditions and add --root-path Wire form fields whose engine-level support already existed but had no route plumbing, plus implement two missing engine pieces. Route wiring (already-implemented engine fields): - waitWindowStatus -> WaitCondition::WindowStatus - failOnResourceHttpStatusCodes -> ctx.fail_on_resource_status - failOnResourceLoadingFailed -> ctx.fail_on_resource_loading_failed - failOnConsoleExceptions -> ctx.fail_on_console_exceptions New engine fields + plumbing: - skip_network_idle: bool — overrides the engine's default networkIdle race during navigation. Wired to skipNetworkIdleEvent and skipNetworkAlmostIdleEvent (Chrome does not distinguish the two via CDP, so both map to a single flag). - ignore_resource_status_domains: Vec<String> — substring match against resource URL hosts; matched resources are exempt from fail_on_resource_status. Accepts JSON array or comma/newline list. Bonus config flag: - --root-path / API_ROOT_PATH — mounts the entire router under a path prefix via Router::nest. Empty default is a no-op. Validated for leading slash, no trailing slash, no consecutive slashes. Tests: server 152 -> 170 (+18), engine 145 -> 150 (+5). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(bench): correct multipart field name and add fixture_filename/field overrides Gotenberg and Folio both require HTML files to be uploaded with field name "files" and filename "index.html". The bench was using the raw filesystem filename as the multipart field name, causing 400s on all HTML workloads. Added fixture_field (multipart field name) and fixture_filename (Content-Disposition filename override) to WorkloadDef, and a --skip flag to exclude workloads by name (e.g. --skip url-local when no fixture server is running). Also includes first benchmark results: pdfengines at parity with Gotenberg; HTML/LibreOffice slower due to unsupported Chrome 147 (chromiumoxide max is 142) and likely engine cold-start cost. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat: complete production hardening implementation (spec-60) * fix: add missing cfg guards for chromium/libreoffice single-feature builds - preview.rs non-chromium stubs: return ApiResult<Response> instead of ApiResult<impl IntoResponse> — compiler needs a concrete Ok type when the function only has an Err branch - webhook/mod.rs: gate chromium match arms with #[cfg(feature = "chromium")] and libreoffice arm with #[cfg(feature = "libreoffice")]; add fallback arms that return an error when the feature is off Fixes the Docker builder-libreoffice and builder-chromium stages. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(webhook): wire SSRF check, configurable retry, allow/deny filters The webhook delivery path was effectively unauthenticated against SSRF attacks despite the existence of validate_webhook_url — that function was exported but never actually called. This commit closes that bug and also brings the retry surface up to Gotenberg parity. SSRF fix: - Call validate_webhook_url on both the success and error webhook URLs before any job is enqueued. Failures surface as 400 with the failing header attributed in the error message. - Refactored into a small validate_webhook_config helper so the wiring is unit-testable without spinning up an AppState. Configurable retry / timeout (matches Gotenberg flag names): - --webhook-max-retry (default 4, env WEBHOOK_MAX_RETRY) - --webhook-retry-min-wait (default 1s, env WEBHOOK_RETRY_MIN_WAIT) - --webhook-retry-max-wait (default 30s, env WEBHOOK_RETRY_MAX_WAIT) - --webhook-client-timeout (default 30s, env WEBHOOK_CLIENT_TIMEOUT) - WebhookClient now takes a WebhookClientConfig struct; main.rs builds it from ServerConfig. Exponential backoff with jitter: - Replaces the fixed 5s retry_delay. Delay = min(min_wait * 2^(attempt-1), max_wait) with full jitter sourced from SystemTime nanoseconds — no rand dependency. Caps shift at 31 to prevent overflow on huge attempt counts. Allow / deny regex lists: - --webhook-allow-list / --webhook-deny-list (repeatable, regex patterns) - WebhookUrlValidator compiles patterns once at startup; bad patterns abort with the offending pattern in the error message. - Layered: SSRF first, then allow-list (if non-empty must match), then deny-list (must not match). Deny takes precedence when both match. - Stored on AppState as Arc<WebhookUrlValidator>; default validator enforces SSRF only. Tests: server 152 -> 168 (+16). All pre-existing webhook tests continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * docs: replace README, add comparison.md + markdown-plus, archive specs - README.md: leaner, ~265 lines (was ~615). Drops marketing comparison table and inline 32-row spec list; foregrounds operator console as the real differentiator vs Gotenberg; calls out deliberate gaps (TLS, RBAC) and empty placeholders explicitly. - comparison.md (new, root): in-depth audit vs Gotenberg in 16 sections — endpoint matrix, per-engine feature tables, what-we-did / didn't-do / shouldn't-do scorecards. - docs/markdown-plus.md (new): design proposal for an enhanced Markdown route (front-matter, math, mermaid, syntax highlighting, includes, themes). Sits alongside the basic markdown route, not a replacement. - docs/specs/ → docs/specs-archive-2026-05-01.zip. 32 legacy spec files archived; fresh contributor-facing specs will be re-introduced under docs/ in better-organised form. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * docs: spec for Python and Node bindings (v1 = conversion, v2 designed) v1 ships HTML/URL/Markdown/Office to PDF for Python (sync Folio + async AsyncFolio) and Node (async). Chrome auto-download lives in a new engine::chrome_fetch module so the CLI/server can opt in later. v2 (full parity: screenshots + PDF ops) is fully specified and deferred. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * docs: implementation plan for v1 bindings (12 TDD tasks) Plan covers chrome_fetch module, PyO3 sync+async Folio, napi-rs async Folio, maturin/napi-rs packaging, smoke + E2E tests, CI matrix. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(bench): full Docker benchmark with correct folio target and Gotenberg allow-list - docker-compose.bench.yml: add target: folio so the full image (chromium + libreoffice) is built, not the last lambda stage - docker-compose.bench.yml: add --chromium-allow-list=.* to Gotenberg so host.docker.internal URLs are not blocked (SSRF protection) - Results: bench/results/20260501T123054Z/perf.md — Folio leads on Chromium HTML (1.3–1.5×) and pdfengines (2.3×); Gotenberg leads on LibreOffice (2.4×, ships LO 26.2 vs Folio's 7.4) Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine): scaffold chrome_fetch module behind feature flag Adds the chrome-fetch feature to the engine crate, wiring optional deps (reqwest, sha2, zip, flate2, tar, dirs) and a skeleton chrome_fetch module with stub submodules ready for Tasks 2-4 to implement. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine): chrome_fetch::detect with injectable lookup for tests Implements detect_system_chrome() with a testable detect_with() helper that accepts injectable env vars, path lookup, and exists functions. Adds minimal placeholders for cache.rs and download.rs (Tasks 3 & 4) and adds missing doc comments to EnsureOptions fields to satisfy the crate-level missing_docs deny lint. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine): chrome_fetch::cache for platform cache dir + lookup Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine): chrome_fetch::download fetches + extracts pinned Chrome Replaces the Task-4 placeholder with the real Chrome-for-Testing downloader: fetches the per-version manifest, streams the zip archive, extracts atomically via a .partial staging dir → rename, and chmod 755s chrome binaries on unix. walkdir added as optional dep gated behind the chrome-fetch feature. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(py): scaffold PyO3 module with sync Folio + error hierarchy Implements Task 5: PyO3 _native module with sync Folio class, error hierarchy mapped from EngineError, tokio runtime singleton, and JSON round-trip for engine option types via serde_json. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(py): maturin project + smoke tests + Python package shim Wire up bindings/python/ as a maturin mixed project: pyproject.toml targets crates/py, folio/__init__.py re-exports all public symbols from _native, and tests/test_smoke.py verifies 3 structural checks (exports, error hierarchy, class methods) without launching Chrome. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(py): AsyncFolio with pyo3-async-runtimes tokio bridge Implement AsyncFolio using pyo3-async-runtimes 0.22.0 (matched to PyO3 0.22 workspace dep). Engine futures are bridged to the caller's running event loop via `future_into_py`; the tokio runtime builder is registered in `_native` init. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(js): napi-rs module with async Folio + error tagging Implements Task 8: napi-rs cdylib with async Folio class exposing html_to_pdf, url_to_pdf, markdown_to_pdf, office_to_pdf, and close. Error tagging convention ([Tag] prefix) wires into Task 9 JS loader. Also adds tokio_rt/serde-json features and napi-build to workspace deps. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(js): npm package + JS error decoration + smoke tests Adds bindings/node with package.json (@folio/folio), a hand-written index.js that wraps the napi-rs native loader (_native.js) with typed error subclasses (FolioError hierarchy), TypeScript declarations, and 4 vitest smoke tests that all pass. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * docs: add LibreOffice performance design spec (unoserver + LO 26.x) * chore(js): add @types/node so Buffer type resolves in index.d.ts * docs: clarify discover.rs removal rationale in LO performance spec * test: e2e render gated on FOLIO_E2E for both bindings Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * ci: bindings build + smoke matrix for python and node Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * docs: add bindings entry to root README + bindings overview * docs: add bindings entry to root README + bindings overview * docs: add LibreOffice performance implementation plan (unoserver + LO 26.x) * chore(engine): add reqwest as optional libreoffice-feature dep * feat(engine/lo): add UnoserverProcess — spawn, ready-poll, drop/SIGTERM Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine/lo): replace soffice subprocess with HTTP POST to unoserver Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(engine/lo): strip 'pdf:' prefix from filtername for unoserver API * docs(engine/lo): clarify filtername format difference in convert.rs comment * feat(engine/lo): wire UnoserverProcess into LibreOfficeEngine — replace soffice subprocess with unoserver - Replace Inner.exe with UnoserverProcess managed under Mutex - Add unoserver_port and unoserver_ready_timeout fields to LibreOfficeConfig - launch() now spawns unoserver and waits for readiness; background task restarts on crash - healthy() replaced: HTTP GET to unoserver instead of soffice --version probe - Remove logger(), discover module reference, and launch_with_missing_executable_path_errors test - Update convert_timeout_kills_child integration test to use unoserver_ready_timeout Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(engine/lo): exit crash monitor loop after exhausting retries; fix stale doc * chore(engine/lo): delete discover.rs — superseded by unoserver ready-polling * feat(server): expose libreoffice_unoserver_port and unoserver_ready_timeout config knobs * feat(docker): upgrade LibreOffice to TDF 26.2, add unoserver, set SAL_USE_VCLPLUGIN=svp * fix(docker): add gnupg to common stage for gpg --dearmor; bump folio start-period to 30s * fix: document update_indexes gap; make unoserver port env parse fail loudly * fix: replace non-existent TDF apt repo with Debian bookworm-backports deb.libreoffice.org is NXDOMAIN — TDF does not publish an apt repo. Switch both LibreOffice-bearing image stages to install LO from the official Debian bookworm-backports repository instead, which ships a significantly newer version than bookworm's 7.4. Also remove the now-unused LIBREOFFICE_VERSION ARG and gnupg package (no longer needed since there's no GPG key to import). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: invoke unoserver binary directly instead of python3 -m unoserver python3 -m unoserver fails with 'No module named unoserver.__main__' because the package has no __main__.py. The pip-installed unoserver provides a /usr/local/bin/unoserver entry-point script that correctly starts the server. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: implement XML-RPC client for unoserver 2.x conversion API unoserver 2.x exposes a SimpleXMLRPCServer, not a REST/multipart API. Rewrite convert.rs to: - Base64-encode the input file - POST an XML-RPC methodCall to the convert() function - Decode the base64-encoded PDF bytes from the response Key fixes along the way: - convert_to must be "pdf" (not nil) when outpath is nil - filter_options must be an empty XML-RPC array, not nil (Python iterates it) - Strip whitespace from base64 response (Python wraps at 76 chars with newlines) Also fix the unoserver spawn test to accept Internal error when the binary is not installed on the host. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * bench: add performance results for lo-performance branch libreoffice-docx p50: 254ms (target: ≤550ms, baseline: ~1256ms) ~5× improvement over the soffice-per-request baseline. 37% faster than Gotenberg (406ms p50). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: resolve post-merge compilation errors - Remove duplicate reqwest dep in engine/Cargo.toml from bindings merge - Add MultipleValidationErrors arms to body() and Display for ApiError - Fix spurious `mut mp` keyword in multipart::from_multipart call - Add missing BrowserConfig fields (max_*) in browser_config_from Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * Cascade snapshot 2026-05-01T21:20:55.956424Z * fix: update test fixtures for new struct fields from merged branches - Add max_page_memory_mb/max_browser_memory_mb/max_concurrent_renders to BrowserConfig test initializer (feature/chromium-wait-conditions) - Add skip_network_idle/ignore_resource_status_domains to RequestContext test initializer (feature/chromium-wait-conditions) - Add api_root_path, libreoffice_unoserver_*, and webhook_* fields to ServerConfig test helpers in all server integration tests - Add NavigationTimeout/RenderTimeout/IdleTimeout/ResourceTimeout/ LibreOfficeTimeout arms to clone_engine_error in router tests Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: resolve all cargo check warnings and errors - Import Embed trait for rust-embed ConsoleAssets (fixes missing 'get' method) - Remove unused imports (Embed, PyStringMethods) - Prefix unused variables with underscore (concurrency_max, headers) - Auto-fixes from cargo fix for unused imports and variables * fix: font_doctor multipart boundary and add metadata/bookmarks round-trip tests - Fix font_doctor_tests multipart format: use '--{boundary}' not '------{boundary}' - Add merge_then_write_metadata_round_trips_author integration test - Add merge_then_write_bookmarks_round_trips integration test Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: correct multipart boundary format in optimise and estimator tests Body delimiters used 6 extra dashes before the boundary variable instead of the RFC-correct `--<boundary>` prefix, causing multipart parse failures and 400 responses. Also adds BDD teststore fixtures and unused import. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(bdd): skip LibreOffice scenarios and serialize teststore access Two root causes for 21 BDD failures: 1. LibreOffice not installed in dev env — added @Skip to 17 libreoffice/convert scenarios (all that expect 200) and the GET /health scenario that asserts libreoffice.status: "up". 2. Concurrent scenarios sharing teststore/foo.pdf — merge round-trip tests (Metadata, Bookmarks List, Auto-index) were racing: one scenario could overwrite foo.pdf before the other's read step ran. Fixed with max_concurrent_scenarios(1) in the cucumber runner. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(test): run BDD tests in Docker with full Chromium + LibreOffice stack Reverts the @Skip workarounds — tests should pass with proper deps. Updates Dockerfile.test to mirror the production image: - Chromium (with FOLIO_NO_SANDBOX=true for running as root) - LibreOffice from bookworm-backports + python3-uno + unoserver==2.2.1 - folio-server binary built so the BDD runner can spawn it - CMD runs both cargo test (unit) and cargo test --test bdd Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(Dockerfile.test): use JSON array form for CMD Resolves JSONArgsRecommended lint warning. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(Dockerfile.test): drop redundant bdd test command cargo test already runs all tests including the bdd integration suite. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(Dockerfile.test): pre-compile test binaries to avoid rebuild on run cargo test --no-run compiles all test binaries during docker build. cargo test --no-fail-fast at runtime just executes them, no recompile. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(test): unbreak engine + e2e integration tests - libreoffice filter_options now emits "Name=Value" strings unoserver understands; previously the JSON blob caused "not enough values to unpack" faults - LibreOffice engines pick a free port when unoserver_port=0; integration tests share one engine via OnceCell instead of relaunching per-test on fixed port 2003 (the previous setup raced the dying process) - Chromium engine gets a per-instance tempdir for --user-data-dir so rapid sequential launches no longer collide on chromiumoxide's default /tmp/chromiumoxide-runner SingletonLock - e2e spawn_server now calls .start() on the supervised engines (matches production main.rs), fixing "Chromium engine not available" 500s - Dockerfile.test runs with --test-threads=1 - LibreOfficeEngine doctest re-enabled as no_run Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> --------- Co-authored-by: __deesh__ <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]>
been-there-done-that
added a commit
that referenced
this pull request
May 3, 2026
* docs: add Folio Operator Console design spec and create ui/ scaffold Spec covers layout (B · Bars edition wireframe), server additions (ConsoleStore, metrics history, request/error ring buffers, /_/api/metrics JSON endpoint, rust-embed static serving), full frontend component tree, API contract, build integration, and Docker changes. ui/ folder contains the SvelteKit + Svelte 5 + Tailwind v4 + shadcn-svelte scaffold created by the user. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * docs: update operator console spec — SSE transport + shadcn charts Replace frontend polling with Server-Sent Events: - Rust: broadcast channel in ConsoleStore, console_stream SSE handler, immediate snapshot on connect, keep-alive ping every 15s - Frontend: EventSource in metrics.svelte.ts, auto-reconnect, connected state Replace custom SVG BarChart with shadcn-svelte chart primitives: - npx shadcn-svelte add chart installs ChartContainer + layerchart bars - Remap --chart-1..5 CSS vars to semantic ok/warn/err/accent colors - Remove BarChart.svelte and MiniBars.svelte from component list Also: note EventSource cannot send auth headers → /_/api/stream is intentionally unauthenticated; operators use FOLIO_DISABLE_CONSOLE=true + reverse proxy if auth is required. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * docs: add operator console implementation plan 16-task plan covering Rust backend (ConsoleStore, SSE handler, static asset serving) and Svelte frontend (metrics store, all dashboard strips) for the Folio Operator Console. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console): add ConsoleStore with ring buffers and SSE broadcast channel Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): remove unused Ordering import, add Default impl, clarify p95 comment * feat(console): expose is_running() on supervised engines for console sampler Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): use imported Ordering alias in is_running() methods * feat(console): add ConsolePayload builder and background sampler task Adds ConsolePayload structs, build_console_payload(), spawn_console_sampler() to console_store.rs and wires the sampler into main.rs after AppState creation. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): interval-relative error_pct, single gather(), cfg engine guards, consistent started_at Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console): add SSE stream and one-shot metrics JSON endpoints Mount /_/api/stream (SSE) and /_/api/metrics (JSON) in the untimed router; add futures-util dependency to the server crate. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console): add request log middleware feeding ConsoleStore ring buffer Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console): serve embedded Svelte SPA at /_/ via rust-embed Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): replace HeaderValue unwrap with safe fallback in serve_asset * feat(console): add ui-build Makefile target and ui-builder Docker stage * fix(console): include bun.lock in ui-builder Docker COPY for layer cache correctness * feat(console-ui): configure base path, remap chart colors, install shadcn chart Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(console-ui): add ConsolePayload types, SSE store, and theme store Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console-ui): hardcode SSE URL to avoid deprecated base import from $app/paths * fix(console-ui): add Theme type alias, clear loading on SSE error * feat(console-ui): add Card, Pill, SlimBar shared primitives * feat(console-ui): add Header and Ticker components * feat(console-ui): add RoutesTable component * feat(console-ui): add side rail — Engines, Concurrency, Batches, Resources Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: correct concurrency grid columns (16→32) and engine status casing Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat: add ThroughputStrip and ActivityStrip components CSS-based bar charts for RPS/p95 sparklines with reference lines; log tables for request and error activity. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat: complete dashboard page layout, tweaks panel, fix Svelte 5 store exports Refactor theme and metrics stores to class-based $state (Svelte 5 module constraint: exported $state cannot be reassigned, derived cannot be exported). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * Cascade snapshot 2026-05-01T09:21:31.235976Z * fix(console): wire metrics, probe engine health, add interactive charts Backend: - Wire record_http_request() in console_log_middleware → fixes RPS, routes, error%, latency percentiles (were all zero — method was never called) - Probe engine health via healthy().await each sampler tick instead of reading a gauge that only updated on /health polls → engines now show correct status - Add sysinfo for cross-platform CPU + memory (replaces Linux-only /proc reads) - Compute global p95 from http_request_duration_seconds histogram - Compute per-route p50/p95/p99 from histogram buckets (linear interpolation) - Add /_ → /_/ 308 redirect Frontend: - Add interactive BarSeries.svelte (SVG bars, hover tooltip, cursor line) - Use BarSeries in ThroughputStrip, Resources, Engines mini-chart - Add slot hover tooltips to Concurrency grid - Engines: full-width mini chart, rename "restarts" → "activations" - Reduce card border-radius 12→6px, pill border-radius to 4px Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(console): track live concurrency via AtomicU32, not sampled semaphore The semaphore is only acquired for PDF conversion requests, so at idle (health checks, browsing) it always showed 0. Even during conversions, fast requests (<5s) completed between sampler ticks and were invisible. Add active_requests: AtomicU32 to ConsoleStore. The middleware increments it before next.run() and decrements after, giving real-time tracking of all in-flight HTTP requests. build_console_payload now reads this atomic instead of state.sem.available_permits(). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: terminate SSE streams on graceful shutdown to prevent SIGTERM hang When SIGTERM/SIGINT fires, axum stops accepting new connections but waits for all in-flight requests to drain. SSE connections never complete on their own, so the server would hang until browsers disconnected. Added a watch::Sender<bool> to ConsoleStore. On shutdown the signal handler sends true before handing off to axum's drain phase. The SSE unfold loop selects on both the broadcast receiver and the shutdown watch, returning None immediately when true is received. Regular requests (downloads, conversions) are unaffected. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * chore(deps): replace uuid with ulid for better identifier properties ULID provides: - Lexicographic sorting (chronological order without timestamp field) - 26 lowercase characters (Crockford base32) - URL-safe encoding without special characters - Better collision resistance than UUIDv4 Refs: spec-60-production-hardening.md Part A * refactor(server): migrate all identifiers from UUIDv4 to ULID Changes: - UuidRequestId → UlidRequestId (lowercase 26-char ULIDs) - BatchId now uses ULID format with 'batch_' prefix - Webhook job IDs use ULID - Add ulid_utils.rs with validation helpers: - is_valid_ulid() - validates 26-char Crockford base32 format - parse_ulid() - parses with proper error handling - extract_ulid_from_prefixed() - for batch IDs - generate_ulid() - creates lowercase ULID strings Benefits: - Chronological sorting without timestamp fields - URL-safe identifiers - Consistent lowercase format Refs: spec-60-production-hardening.md Part A * feat(security): implement SSRF prevention and header injection protection SSRF Prevention (url_validator.rs): - Block private IP ranges (10.x, 192.168.x, 172.16-31.x, 127.x) - Block link-local addresses (169.254.x, fe80::) - Block localhost and *.local domains - Support allowlist-only mode for strict deployments - CIDR-based matching for IPv4 and IPv6 Header Injection Prevention (header_validator.rs): - Detect and block CRLF in header names/values - Block dangerous headers (Host, Content-Length, Transfer-Encoding, Cookie, Authorization) - Null byte detection - Header value length limits (8KB) - Max header count limits (50) Refs: spec-60-production-hardening.md Part B * feat(server): add multipart security limits and configuration Add MultipartSecurityConfig with configurable limits: - Max field name length: 256 chars (default), 128 (strict) - Max field value length: 1 MB (default), 64 KB (strict) - Max file count: 100 (default), 10 (strict) - Max filename length: 255 (default), 100 (strict) Changes: - from_multipart() now delegates to from_multipart_with_config() - Enforces field name length before processing - Enforces file count and filename length for uploads - Enforces field value length for non-file fields - Add strict() configuration for high-security environments Refs: spec-60-production-hardening.md Part B, Part F * feat(engine): add LibreOffice macro security isolation SECURITY: Prevent macro execution in office documents Implementation: - Create macro security policy file in UserInstallation directory - Set MacroSecurityLevel=3 (highest security - never execute macros) - Policy file created before each conversion in isolated temp profile Protects against: - Malicious macros in Word, Excel, PowerPoint files - Auto-running macros on document open - Macro-based exploits targeting LibreOffice Refs: spec-60-production-hardening.md Part B * docs(specs): add comprehensive production hardening specification Spec 60 covers: - Part A: ULID migration (UUID → ULID) - Part B: Security hardening (SSRF, header injection, path traversal, macro isolation) - Part C: Error handling improvements (timeout classification, partial success, multi-validation) - Part D: Resource management (memory limits, zombie cleanup, concurrency limits) - Part E: Robustness improvements (PDF validation, output validation, recovery) - Part F: Server robustness (multipart limits, circuit breaker, batch timeouts, graceful shutdown) Includes implementation details, test plans, and acceptance criteria. * chore: gitignore .worktrees/ and tmp/ Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(errors): add timeout classification variants Add granular timeout error types to EngineError: - NavigationTimeout { url, duration } - page load timeout - RenderTimeout(duration) - PDF generation hang - IdleTimeout(duration) - network idle not reached - ResourceTimeout { url, duration } - sub-resource timeout - LibreOfficeTimeout(duration) - document conversion timeout Server changes: - Map each timeout type to specific error code - Add detailed error responses with suggestions per timeout type - Add documentation links for each timeout variant Benefits: - Better diagnostics for timeout failures - Specific suggestions based on timeout type - Enables targeted retry strategies Refs: spec-60-production-hardening.md Part C * feat(errors): add partial success and multiple validation error support Add partial success types to engine: - WarningSeverity enum (Info, Warning, Critical) - ConversionWarning struct for non-fatal issues - ConversionResult with PDF bytes + warnings + page count - PartialSuccessOptions trait for fail_on_resource_error option Add multiple validation error support to server: - FieldError struct with field, message, value - MultipleValidationErrors variant in ApiError - Collect all validation errors instead of failing on first - Detailed error response listing all invalid fields Refs: spec-60-production-hardening.md Part C * feat(chromium): wire spec-36 wait/fail conditions and add --root-path Wire form fields whose engine-level support already existed but had no route plumbing, plus implement two missing engine pieces. Route wiring (already-implemented engine fields): - waitWindowStatus -> WaitCondition::WindowStatus - failOnResourceHttpStatusCodes -> ctx.fail_on_resource_status - failOnResourceLoadingFailed -> ctx.fail_on_resource_loading_failed - failOnConsoleExceptions -> ctx.fail_on_console_exceptions New engine fields + plumbing: - skip_network_idle: bool — overrides the engine's default networkIdle race during navigation. Wired to skipNetworkIdleEvent and skipNetworkAlmostIdleEvent (Chrome does not distinguish the two via CDP, so both map to a single flag). - ignore_resource_status_domains: Vec<String> — substring match against resource URL hosts; matched resources are exempt from fail_on_resource_status. Accepts JSON array or comma/newline list. Bonus config flag: - --root-path / API_ROOT_PATH — mounts the entire router under a path prefix via Router::nest. Empty default is a no-op. Validated for leading slash, no trailing slash, no consecutive slashes. Tests: server 152 -> 170 (+18), engine 145 -> 150 (+5). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(bench): correct multipart field name and add fixture_filename/field overrides Gotenberg and Folio both require HTML files to be uploaded with field name "files" and filename "index.html". The bench was using the raw filesystem filename as the multipart field name, causing 400s on all HTML workloads. Added fixture_field (multipart field name) and fixture_filename (Content-Disposition filename override) to WorkloadDef, and a --skip flag to exclude workloads by name (e.g. --skip url-local when no fixture server is running). Also includes first benchmark results: pdfengines at parity with Gotenberg; HTML/LibreOffice slower due to unsupported Chrome 147 (chromiumoxide max is 142) and likely engine cold-start cost. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat: complete production hardening implementation (spec-60) * fix: add missing cfg guards for chromium/libreoffice single-feature builds - preview.rs non-chromium stubs: return ApiResult<Response> instead of ApiResult<impl IntoResponse> — compiler needs a concrete Ok type when the function only has an Err branch - webhook/mod.rs: gate chromium match arms with #[cfg(feature = "chromium")] and libreoffice arm with #[cfg(feature = "libreoffice")]; add fallback arms that return an error when the feature is off Fixes the Docker builder-libreoffice and builder-chromium stages. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(webhook): wire SSRF check, configurable retry, allow/deny filters The webhook delivery path was effectively unauthenticated against SSRF attacks despite the existence of validate_webhook_url — that function was exported but never actually called. This commit closes that bug and also brings the retry surface up to Gotenberg parity. SSRF fix: - Call validate_webhook_url on both the success and error webhook URLs before any job is enqueued. Failures surface as 400 with the failing header attributed in the error message. - Refactored into a small validate_webhook_config helper so the wiring is unit-testable without spinning up an AppState. Configurable retry / timeout (matches Gotenberg flag names): - --webhook-max-retry (default 4, env WEBHOOK_MAX_RETRY) - --webhook-retry-min-wait (default 1s, env WEBHOOK_RETRY_MIN_WAIT) - --webhook-retry-max-wait (default 30s, env WEBHOOK_RETRY_MAX_WAIT) - --webhook-client-timeout (default 30s, env WEBHOOK_CLIENT_TIMEOUT) - WebhookClient now takes a WebhookClientConfig struct; main.rs builds it from ServerConfig. Exponential backoff with jitter: - Replaces the fixed 5s retry_delay. Delay = min(min_wait * 2^(attempt-1), max_wait) with full jitter sourced from SystemTime nanoseconds — no rand dependency. Caps shift at 31 to prevent overflow on huge attempt counts. Allow / deny regex lists: - --webhook-allow-list / --webhook-deny-list (repeatable, regex patterns) - WebhookUrlValidator compiles patterns once at startup; bad patterns abort with the offending pattern in the error message. - Layered: SSRF first, then allow-list (if non-empty must match), then deny-list (must not match). Deny takes precedence when both match. - Stored on AppState as Arc<WebhookUrlValidator>; default validator enforces SSRF only. Tests: server 152 -> 168 (+16). All pre-existing webhook tests continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * docs: replace README, add comparison.md + markdown-plus, archive specs - README.md: leaner, ~265 lines (was ~615). Drops marketing comparison table and inline 32-row spec list; foregrounds operator console as the real differentiator vs Gotenberg; calls out deliberate gaps (TLS, RBAC) and empty placeholders explicitly. - comparison.md (new, root): in-depth audit vs Gotenberg in 16 sections — endpoint matrix, per-engine feature tables, what-we-did / didn't-do / shouldn't-do scorecards. - docs/markdown-plus.md (new): design proposal for an enhanced Markdown route (front-matter, math, mermaid, syntax highlighting, includes, themes). Sits alongside the basic markdown route, not a replacement. - docs/specs/ → docs/specs-archive-2026-05-01.zip. 32 legacy spec files archived; fresh contributor-facing specs will be re-introduced under docs/ in better-organised form. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * docs: spec for Python and Node bindings (v1 = conversion, v2 designed) v1 ships HTML/URL/Markdown/Office to PDF for Python (sync Folio + async AsyncFolio) and Node (async). Chrome auto-download lives in a new engine::chrome_fetch module so the CLI/server can opt in later. v2 (full parity: screenshots + PDF ops) is fully specified and deferred. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * docs: implementation plan for v1 bindings (12 TDD tasks) Plan covers chrome_fetch module, PyO3 sync+async Folio, napi-rs async Folio, maturin/napi-rs packaging, smoke + E2E tests, CI matrix. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(bench): full Docker benchmark with correct folio target and Gotenberg allow-list - docker-compose.bench.yml: add target: folio so the full image (chromium + libreoffice) is built, not the last lambda stage - docker-compose.bench.yml: add --chromium-allow-list=.* to Gotenberg so host.docker.internal URLs are not blocked (SSRF protection) - Results: bench/results/20260501T123054Z/perf.md — Folio leads on Chromium HTML (1.3–1.5×) and pdfengines (2.3×); Gotenberg leads on LibreOffice (2.4×, ships LO 26.2 vs Folio's 7.4) Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine): scaffold chrome_fetch module behind feature flag Adds the chrome-fetch feature to the engine crate, wiring optional deps (reqwest, sha2, zip, flate2, tar, dirs) and a skeleton chrome_fetch module with stub submodules ready for Tasks 2-4 to implement. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine): chrome_fetch::detect with injectable lookup for tests Implements detect_system_chrome() with a testable detect_with() helper that accepts injectable env vars, path lookup, and exists functions. Adds minimal placeholders for cache.rs and download.rs (Tasks 3 & 4) and adds missing doc comments to EnsureOptions fields to satisfy the crate-level missing_docs deny lint. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine): chrome_fetch::cache for platform cache dir + lookup Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine): chrome_fetch::download fetches + extracts pinned Chrome Replaces the Task-4 placeholder with the real Chrome-for-Testing downloader: fetches the per-version manifest, streams the zip archive, extracts atomically via a .partial staging dir → rename, and chmod 755s chrome binaries on unix. walkdir added as optional dep gated behind the chrome-fetch feature. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(py): scaffold PyO3 module with sync Folio + error hierarchy Implements Task 5: PyO3 _native module with sync Folio class, error hierarchy mapped from EngineError, tokio runtime singleton, and JSON round-trip for engine option types via serde_json. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(py): maturin project + smoke tests + Python package shim Wire up bindings/python/ as a maturin mixed project: pyproject.toml targets crates/py, folio/__init__.py re-exports all public symbols from _native, and tests/test_smoke.py verifies 3 structural checks (exports, error hierarchy, class methods) without launching Chrome. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(py): AsyncFolio with pyo3-async-runtimes tokio bridge Implement AsyncFolio using pyo3-async-runtimes 0.22.0 (matched to PyO3 0.22 workspace dep). Engine futures are bridged to the caller's running event loop via `future_into_py`; the tokio runtime builder is registered in `_native` init. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(js): napi-rs module with async Folio + error tagging Implements Task 8: napi-rs cdylib with async Folio class exposing html_to_pdf, url_to_pdf, markdown_to_pdf, office_to_pdf, and close. Error tagging convention ([Tag] prefix) wires into Task 9 JS loader. Also adds tokio_rt/serde-json features and napi-build to workspace deps. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(js): npm package + JS error decoration + smoke tests Adds bindings/node with package.json (@folio/folio), a hand-written index.js that wraps the napi-rs native loader (_native.js) with typed error subclasses (FolioError hierarchy), TypeScript declarations, and 4 vitest smoke tests that all pass. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * docs: add LibreOffice performance design spec (unoserver + LO 26.x) * chore(js): add @types/node so Buffer type resolves in index.d.ts * docs: clarify discover.rs removal rationale in LO performance spec * test: e2e render gated on FOLIO_E2E for both bindings Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * ci: bindings build + smoke matrix for python and node Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * docs: add bindings entry to root README + bindings overview * docs: add bindings entry to root README + bindings overview * docs: add LibreOffice performance implementation plan (unoserver + LO 26.x) * chore(engine): add reqwest as optional libreoffice-feature dep * feat(engine/lo): add UnoserverProcess — spawn, ready-poll, drop/SIGTERM Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(engine/lo): replace soffice subprocess with HTTP POST to unoserver Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(engine/lo): strip 'pdf:' prefix from filtername for unoserver API * docs(engine/lo): clarify filtername format difference in convert.rs comment * feat(engine/lo): wire UnoserverProcess into LibreOfficeEngine — replace soffice subprocess with unoserver - Replace Inner.exe with UnoserverProcess managed under Mutex - Add unoserver_port and unoserver_ready_timeout fields to LibreOfficeConfig - launch() now spawns unoserver and waits for readiness; background task restarts on crash - healthy() replaced: HTTP GET to unoserver instead of soffice --version probe - Remove logger(), discover module reference, and launch_with_missing_executable_path_errors test - Update convert_timeout_kills_child integration test to use unoserver_ready_timeout Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(engine/lo): exit crash monitor loop after exhausting retries; fix stale doc * chore(engine/lo): delete discover.rs — superseded by unoserver ready-polling * feat(server): expose libreoffice_unoserver_port and unoserver_ready_timeout config knobs * feat(docker): upgrade LibreOffice to TDF 26.2, add unoserver, set SAL_USE_VCLPLUGIN=svp * fix(docker): add gnupg to common stage for gpg --dearmor; bump folio start-period to 30s * fix: document update_indexes gap; make unoserver port env parse fail loudly * fix: replace non-existent TDF apt repo with Debian bookworm-backports deb.libreoffice.org is NXDOMAIN — TDF does not publish an apt repo. Switch both LibreOffice-bearing image stages to install LO from the official Debian bookworm-backports repository instead, which ships a significantly newer version than bookworm's 7.4. Also remove the now-unused LIBREOFFICE_VERSION ARG and gnupg package (no longer needed since there's no GPG key to import). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: invoke unoserver binary directly instead of python3 -m unoserver python3 -m unoserver fails with 'No module named unoserver.__main__' because the package has no __main__.py. The pip-installed unoserver provides a /usr/local/bin/unoserver entry-point script that correctly starts the server. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: implement XML-RPC client for unoserver 2.x conversion API unoserver 2.x exposes a SimpleXMLRPCServer, not a REST/multipart API. Rewrite convert.rs to: - Base64-encode the input file - POST an XML-RPC methodCall to the convert() function - Decode the base64-encoded PDF bytes from the response Key fixes along the way: - convert_to must be "pdf" (not nil) when outpath is nil - filter_options must be an empty XML-RPC array, not nil (Python iterates it) - Strip whitespace from base64 response (Python wraps at 76 chars with newlines) Also fix the unoserver spawn test to accept Internal error when the binary is not installed on the host. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * bench: add performance results for lo-performance branch libreoffice-docx p50: 254ms (target: ≤550ms, baseline: ~1256ms) ~5× improvement over the soffice-per-request baseline. 37% faster than Gotenberg (406ms p50). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: resolve post-merge compilation errors - Remove duplicate reqwest dep in engine/Cargo.toml from bindings merge - Add MultipleValidationErrors arms to body() and Display for ApiError - Fix spurious `mut mp` keyword in multipart::from_multipart call - Add missing BrowserConfig fields (max_*) in browser_config_from Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * Cascade snapshot 2026-05-01T21:20:55.956424Z * fix: update test fixtures for new struct fields from merged branches - Add max_page_memory_mb/max_browser_memory_mb/max_concurrent_renders to BrowserConfig test initializer (feature/chromium-wait-conditions) - Add skip_network_idle/ignore_resource_status_domains to RequestContext test initializer (feature/chromium-wait-conditions) - Add api_root_path, libreoffice_unoserver_*, and webhook_* fields to ServerConfig test helpers in all server integration tests - Add NavigationTimeout/RenderTimeout/IdleTimeout/ResourceTimeout/ LibreOfficeTimeout arms to clone_engine_error in router tests Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: resolve all cargo check warnings and errors - Import Embed trait for rust-embed ConsoleAssets (fixes missing 'get' method) - Remove unused imports (Embed, PyStringMethods) - Prefix unused variables with underscore (concurrency_max, headers) - Auto-fixes from cargo fix for unused imports and variables * fix: font_doctor multipart boundary and add metadata/bookmarks round-trip tests - Fix font_doctor_tests multipart format: use '--{boundary}' not '------{boundary}' - Add merge_then_write_metadata_round_trips_author integration test - Add merge_then_write_bookmarks_round_trips integration test Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: correct multipart boundary format in optimise and estimator tests Body delimiters used 6 extra dashes before the boundary variable instead of the RFC-correct `--<boundary>` prefix, causing multipart parse failures and 400 responses. Also adds BDD teststore fixtures and unused import. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(bdd): skip LibreOffice scenarios and serialize teststore access Two root causes for 21 BDD failures: 1. LibreOffice not installed in dev env — added @Skip to 17 libreoffice/convert scenarios (all that expect 200) and the GET /health scenario that asserts libreoffice.status: "up". 2. Concurrent scenarios sharing teststore/foo.pdf — merge round-trip tests (Metadata, Bookmarks List, Auto-index) were racing: one scenario could overwrite foo.pdf before the other's read step ran. Fixed with max_concurrent_scenarios(1) in the cucumber runner. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(test): run BDD tests in Docker with full Chromium + LibreOffice stack Reverts the @Skip workarounds — tests should pass with proper deps. Updates Dockerfile.test to mirror the production image: - Chromium (with FOLIO_NO_SANDBOX=true for running as root) - LibreOffice from bookworm-backports + python3-uno + unoserver==2.2.1 - folio-server binary built so the BDD runner can spawn it - CMD runs both cargo test (unit) and cargo test --test bdd Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(Dockerfile.test): use JSON array form for CMD Resolves JSONArgsRecommended lint warning. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(Dockerfile.test): drop redundant bdd test command cargo test already runs all tests including the bdd integration suite. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(Dockerfile.test): pre-compile test binaries to avoid rebuild on run cargo test --no-run compiles all test binaries during docker build. cargo test --no-fail-fast at runtime just executes them, no recompile. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(test): unbreak engine + e2e integration tests - libreoffice filter_options now emits "Name=Value" strings unoserver understands; previously the JSON blob caused "not enough values to unpack" faults - LibreOffice engines pick a free port when unoserver_port=0; integration tests share one engine via OnceCell instead of relaunching per-test on fixed port 2003 (the previous setup raced the dying process) - Chromium engine gets a per-instance tempdir for --user-data-dir so rapid sequential launches no longer collide on chromiumoxide's default /tmp/chromiumoxide-runner SingletonLock - e2e spawn_server now calls .start() on the supervised engines (matches production main.rs), fixing "Chromium engine not available" 500s - Dockerfile.test runs with --test-threads=1 - LibreOfficeEngine doctest re-enabled as no_run Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> --------- Co-authored-by: __deesh__ <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.