diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c8ed8..669a462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project uses **CalVer `YY.M.PP`** (PEP 440 may normalise patch numbers for the Python wheel — e.g. `26.06.00` → `26.6.0`). +## [26.6.12] - 2026-06-15 + +### Added + +- **The admin dashboard is now served on the workers, not just the API.** The + `worker` and `bbox-worker` management servers (`worker_health.py`) now build + pyfly's full management app (`create_management_app`) instead of an + actuator-only Starlette app, so each worker's management port exposes the same + surface as the API — the `/admin` dashboard (beans, health, env, config, + loggers, metrics, scheduled, runtime, server, overview) **and** the actuator + endpoints — plus pyfly's structured request access log for management traffic. + +### Changed + +- **Upgraded pyfly to `v26.06.105`.** The management port now access-logs its + requests and its observability (metrics, `/actuator/httpexchanges`, traces) + reflects **both** the application and management ports. +- **`build_health_app(context)` now requires a started context** (no actuator-only + fallback) and always returns the full management app. + ## [26.6.11] - 2026-06-15 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index f0d5e87..a864ea1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "flydocs" # CalVer YY.MM.PP -- bumped per release. Note that PEP 440 normalises # ``26.05.01`` -> ``26.5.1`` in the built wheel filename. -version = "26.6.11" +version = "26.6.12" description = "Pure-multimodal Intelligent Document Processing service: structured fields + bounding boxes, validation, authenticity checks, LLM judge, and a business-rule engine. Sync + queue-backed async APIs over fireflyframework-pyfly and -agentic. Part of Firefly OperationOS, platform-agnostic by design." readme = "README.md" requires-python = ">=3.13" @@ -19,7 +19,7 @@ dependencies = [ # so a fresh ``uv sync`` is enough to boot the full stack. The ``web`` # extra declares starlette + uvicorn, which the worker health server # imports directly; the floor carries ``pyfly.actuator.install_health_indicators``. - "pyfly[fastapi,web,observability,security,data-relational,postgresql,eda,redis,client,scheduling,cli]>=26.6.104", + "pyfly[fastapi,web,observability,security,data-relational,postgresql,eda,redis,client,scheduling,cli]>=26.6.105", # GenAI metaframework -- FireflyAgent with multimodal content (BinaryContent/ImageUrl) # over pydantic-ai. Pulls in the OpenAI / Anthropic / Bedrock providers via pydantic-ai-slim. @@ -131,7 +131,7 @@ override-dependencies = [ # (vestigial) ./vendor clone + Dockerfile BuildKit context for pyfly are now # no-ops — the path-rewrite sed no longer matches a git source — exactly as for # agentic; they can be removed in a later cleanup. -pyfly = { git = "https://github.com/fireflyframework/fireflyframework-pyfly.git", tag = "v26.06.104" } +pyfly = { git = "https://github.com/fireflyframework/fireflyframework-pyfly.git", tag = "v26.06.105" } fireflyframework-agentic = { git = "https://github.com/fireflyframework/fireflyframework-agentic.git", tag = "v26.05.30" } [tool.hatch.build.targets.wheel] diff --git a/src/flydocs/__init__.py b/src/flydocs/__init__.py index 3454fe6..41adfa9 100644 --- a/src/flydocs/__init__.py +++ b/src/flydocs/__init__.py @@ -24,4 +24,4 @@ `PromptRegistry`). """ -__version__ = "26.6.11" +__version__ = "26.6.12" diff --git a/src/flydocs/worker_health.py b/src/flydocs/worker_health.py index 9f07a27..28ea596 100644 --- a/src/flydocs/worker_health.py +++ b/src/flydocs/worker_health.py @@ -56,10 +56,10 @@ from typing import TYPE_CHECKING import uvicorn -from pyfly.actuator import HealthAggregator, build_actuator_routes, install_health_indicators from starlette.applications import Starlette if TYPE_CHECKING: + from pyfly.actuator import HealthAggregator from pyfly.context.application_context import ApplicationContext from flydocs.config import IDPSettings @@ -78,19 +78,35 @@ def resolve_health_port(settings: IDPSettings) -> int: def build_health_app( - context: ApplicationContext | None = None, + context: ApplicationContext, aggregator: HealthAggregator | None = None, ) -> Starlette: - """Starlette app exposing pyfly's actuator routes for a worker process. - - Scans the context's container for ``HealthIndicator`` beans (e.g. - ``database_health``, ``eda_health``) and mounts the actuator route - table over them, honouring the web-exposure config carried by - *context*. + """Management app for a worker process: actuator + admin dashboard. + + Returns pyfly's full management app (``create_management_app``) so a worker + pod exposes the **same management surface as the API** on its health port — + the actuator endpoints **and** the ``/admin`` dashboard (beans, health, env, + config, loggers, metrics, scheduled, runtime, server, overview) — plus + pyfly's structured request access log. Indicator/exposure config is carried + by *context*; callers must invoke this after ``PyFlyApplication.startup()``. """ - agg = aggregator if aggregator is not None else HealthAggregator() - install_health_indicators(context, agg) - return Starlette(routes=build_actuator_routes(context, agg)) + from pyfly.web.adapters.starlette.management_app import create_management_app + + admin_enabled = str(context.config.get("pyfly.admin.enabled", "false")).lower() in ("true", "1", "yes") + trace_collector = None + if admin_enabled: + from pyfly.admin.middleware.trace_collector import TraceCollectorFilter + + trace_collector = TraceCollectorFilter() + return create_management_app( + context, + health_agg=aggregator, + http_exchange_recorder=None, + admin_trace_collector=trace_collector, + actuator_active=True, + admin_enabled=admin_enabled, + base_path="", + ) class _NoSignalServer(uvicorn.Server): @@ -124,10 +140,14 @@ def make_health_server(app: Starlette, *, settings: IDPSettings) -> uvicorn.Serv def build_worker_health_server( - context: ApplicationContext | None, + context: ApplicationContext, settings: IDPSettings, ) -> uvicorn.Server | None: - """Health server for a worker process, or ``None`` when disabled.""" + """Health server for a worker process, or ``None`` when disabled. + + *context* is the worker's started ``ApplicationContext`` (the CLI passes + ``pyfly_app.context``); the management app is built from it. + """ if resolve_health_port(settings) == 0: return None return make_health_server(build_health_app(context), settings=settings) diff --git a/tests/unit/test_worker_health.py b/tests/unit/test_worker_health.py index 8c30fde..65ae078 100644 --- a/tests/unit/test_worker_health.py +++ b/tests/unit/test_worker_health.py @@ -78,7 +78,7 @@ def _free_port() -> int: def test_probes_up_without_indicators() -> None: - client = _client(build_health_app()) + client = _client(build_health_app(ApplicationContext(Config({})))) for path in ("/actuator/health", "/actuator/health/liveness", "/actuator/health/readiness"): resp = client.get(path) assert resp.status_code == 200, path @@ -88,7 +88,7 @@ def test_probes_up_without_indicators() -> None: def test_down_indicator_flips_probes_to_503() -> None: agg = HealthAggregator() agg.add_indicator("database_health", _DownIndicator()) - client = _client(build_health_app(aggregator=agg)) + client = _client(build_health_app(ApplicationContext(Config({})), aggregator=agg)) resp = client.get("/actuator/health/readiness") assert resp.status_code == 503 @@ -99,7 +99,7 @@ def test_down_indicator_flips_probes_to_503() -> None: def test_readiness_only_indicator_does_not_kill_liveness() -> None: agg = HealthAggregator() agg.add_indicator("database_health", _DownIndicator(), groups={ProbeGroup.READINESS}) - client = _client(build_health_app(aggregator=agg)) + client = _client(build_health_app(ApplicationContext(Config({})), aggregator=agg)) assert client.get("/actuator/health/readiness").status_code == 503 resp = client.get("/actuator/health/liveness") @@ -113,7 +113,7 @@ def test_readiness_only_indicator_does_not_kill_liveness() -> None: def test_default_exposure_hides_loggers_and_metrics() -> None: - client = _client(build_health_app()) + client = _client(build_health_app(ApplicationContext(Config({})))) assert client.get("/actuator/health").status_code == 200 assert client.get("/actuator/loggers").status_code == 404 assert client.get("/actuator/metrics").status_code == 404 @@ -130,6 +130,22 @@ def test_exposure_opt_in_follows_pyfly_management_config() -> None: assert client.get("/actuator/loggers").status_code == 404 +def test_worker_serves_admin_dashboard_when_enabled() -> None: + # With a context that enables the admin dashboard, the worker's management + # app exposes /admin alongside the actuator — same surface as the API. + context = ApplicationContext(Config({"pyfly": {"admin": {"enabled": True}}})) + client = _client(build_health_app(context)) + assert client.get("/actuator/health").status_code == 200 + assert client.get("/admin/").status_code in (200, 307, 308) + + +def test_worker_admin_dashboard_absent_when_disabled() -> None: + context = ApplicationContext(Config({"pyfly": {"admin": {"enabled": False}}})) + client = _client(build_health_app(context)) + assert client.get("/actuator/health").status_code == 200 + assert client.get("/admin/").status_code == 404 + + # --------------------------------------------------------------------------- # Indicator discovery from the DI container # --------------------------------------------------------------------------- @@ -175,7 +191,7 @@ def test_resolve_health_port_zero_disables() -> None: def test_make_health_server_config() -> None: settings = IDPSettings(port=8080, worker_health_port=9090) - server = make_health_server(build_health_app(), settings=settings) + server = make_health_server(build_health_app(ApplicationContext(Config({}))), settings=settings) assert server.config.host == "0.0.0.0" assert server.config.port == 9090 assert server.config.access_log is False @@ -185,15 +201,22 @@ def test_make_health_server_config() -> None: def test_make_health_server_rejects_disabled_port() -> None: settings = IDPSettings(port=8080, worker_health_port=0) with pytest.raises(ValueError, match="disabled"): - make_health_server(build_health_app(), settings=settings) + make_health_server(build_health_app(ApplicationContext(Config({}))), settings=settings) def test_build_worker_health_server_none_when_disabled() -> None: - assert build_worker_health_server(None, IDPSettings(port=8080, worker_health_port=0)) is None + assert ( + build_worker_health_server( + ApplicationContext(Config({})), IDPSettings(port=8080, worker_health_port=0) + ) + is None + ) def test_build_worker_health_server_enabled() -> None: - server = build_worker_health_server(None, IDPSettings(port=8080, worker_health_port=9090)) + server = build_worker_health_server( + ApplicationContext(Config({})), IDPSettings(port=8080, worker_health_port=9090) + ) assert server is not None assert server.config.port == 9090 @@ -206,7 +229,9 @@ def test_build_worker_health_server_enabled() -> None: @pytest.mark.asyncio async def test_health_server_serves_and_stops() -> None: port = _free_port() - server = build_worker_health_server(None, IDPSettings(port=port, worker_health_port=None)) + server = build_worker_health_server( + ApplicationContext(Config({})), IDPSettings(port=port, worker_health_port=None) + ) assert server is not None task = asyncio.create_task(serve_health(server), name="health-server") try: @@ -239,7 +264,9 @@ async def test_bind_failure_is_a_normal_task_exception() -> None: blocker.listen(1) port = blocker.getsockname()[1] - server = build_worker_health_server(None, IDPSettings(port=port, worker_health_port=None)) + server = build_worker_health_server( + ApplicationContext(Config({})), IDPSettings(port=port, worker_health_port=None) + ) assert server is not None with pytest.raises(RuntimeError, match="health server"): # wait_for bounds the test: if the server ever binds successfully diff --git a/uv.lock b/uv.lock index 3188806..10b61a6 100644 --- a/uv.lock +++ b/uv.lock @@ -1539,7 +1539,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10.0" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai", "bedrock"], specifier = ">=1.56.0" }, { name = "pydantic-settings", specifier = ">=2.7.0" }, - { name = "pyfly", extras = ["fastapi", "web", "observability", "security", "data-relational", "postgresql", "eda", "redis", "client", "scheduling", "cli"], git = "https://github.com/fireflyframework/fireflyframework-pyfly.git?tag=v26.06.104" }, + { name = "pyfly", extras = ["fastapi", "web", "observability", "security", "data-relational", "postgresql", "eda", "redis", "client", "scheduling", "cli"], git = "https://github.com/fireflyframework/fireflyframework-pyfly.git?tag=v26.06.105" }, { name = "pymupdf", specifier = ">=1.24" }, { name = "pypdf", specifier = ">=4.3.0" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1" }, @@ -3942,8 +3942,8 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.104" -source = { git = "https://github.com/fireflyframework/fireflyframework-pyfly.git?tag=v26.06.104#a921a67fd0184b2caf05813bd3f7e6048e22b976" } +version = "26.6.105" +source = { git = "https://github.com/fireflyframework/fireflyframework-pyfly.git?tag=v26.06.105#9e5ddd2d52071a61cbc24c435b3b001463d41239" } dependencies = [ { name = "pydantic" }, { name = "pyyaml" },