Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion src/flydocs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@
`PromptRegistry`).
"""

__version__ = "26.6.11"
__version__ = "26.6.12"
46 changes: 33 additions & 13 deletions src/flydocs/worker_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
47 changes: 37 additions & 10 deletions tests/unit/test_worker_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading