From 16a6de35e5a1e3d150258e43aaf0f6dee31ea18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Fri, 13 Mar 2026 00:13:09 +0100 Subject: [PATCH 1/2] feat(onboarding): add provider rollout staging --- CHANGELOG.md | 1 + README.md | 4 +- docs/ONBOARDING.md | 10 ++++- foundrygate/onboarding.py | 87 +++++++++++++++++++++++++++++++++++++++ tests/test_onboarding.py | 74 +++++++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 150f345..e69bde2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel - Added `foundrygate-onboarding-report` plus a testable onboarding report module for many-provider and many-client readiness checks - Added `foundrygate-onboarding-validate` so onboarding blockers can fail fast in local setup and CI-style validation flows - Added built-in OpenClaw, n8n, and CLI quickstart examples to the onboarding report and integration docs so client onboarding can stay copy/paste friendly +- Added staged provider-rollout reporting and fallback/image readiness warnings so many-provider onboarding is easier to phase safely ## v0.7.0 - 2026-03-12 diff --git a/README.md b/README.md index 9c2df65..c814386 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ $EDITOR .env ./scripts/foundrygate-onboarding-report ``` -The onboarding report now includes concrete OpenClaw, n8n, and CLI quickstart hints so you can move from a generic health check to a client-specific integration path without leaving the terminal. +The onboarding report now includes concrete OpenClaw, n8n, and CLI quickstart hints plus a staged provider-rollout view, so you can move from a generic health check to a real client and provider rollout path without leaving the terminal. If you prefer the Linux service path instead of a manual Python run, jump to [Helper Scripts](#helper-scripts) and use `./scripts/foundrygate-install`. @@ -839,7 +839,7 @@ Running `./scripts/foundrygate-install` also creates symlinks in `/usr/local/bin | --- | --- | | `foundrygate-bootstrap` | Creates `.env` from `.env.example` if needed, creates a local state dir, and appends a safe local `FOUNDRYGATE_DB_PATH` if none is set | | `foundrygate-doctor` | Checks for config/env presence, writable DB path, at least one configured provider key, and optional local health endpoints | -| `foundrygate-onboarding-report` | Summarizes provider readiness, client-profile coverage, routing layers, onboarding suggestions, and concrete OpenClaw/n8n/CLI quickstarts | +| `foundrygate-onboarding-report` | Summarizes provider readiness, staged rollout readiness, client-profile coverage, routing layers, onboarding suggestions, and concrete OpenClaw/n8n/CLI quickstarts | | `foundrygate-onboarding-validate` | Exits non-zero when onboarding blockers exist and prints warnings for common multi-provider and multi-client misconfigurations | | `foundrygate-install` | Installs the unit file, creates `/var/lib/foundrygate`, creates helper symlinks, reloads `systemd`, and starts the service | | `foundrygate-start` | Runs `systemctl start foundrygate.service` | diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 13882e2..a07ada6 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -26,7 +26,7 @@ $EDITOR .env ./scripts/foundrygate-onboarding-report ``` -`foundrygate-onboarding-report` now includes concrete OpenClaw, n8n, and CLI quickstart hints. Use it after every provider or client change to keep the deployment understandable for the next operator as well. +`foundrygate-onboarding-report` now includes concrete OpenClaw, n8n, and CLI quickstart hints plus a staged provider-rollout view. Use it after every provider or client change to keep the deployment understandable for the next operator as well. ### 1. Add one provider @@ -58,6 +58,14 @@ For many-provider rollouts, run the onboarding report after every provider chang ./scripts/foundrygate-onboarding-validate ``` +The rollout section is intentionally staged: + +1. stage 1 primary: ready local/default chat providers +2. stage 2 secondary: additional non-image providers +3. stage 3 modality: image-capable providers + +This keeps provider growth incremental instead of introducing chat, fallback, and modality changes all at once. + ## Client onboarding sequence ### 1. Keep the client on the common API diff --git a/foundrygate/onboarding.py b/foundrygate/onboarding.py index f27ffd3..bc03477 100644 --- a/foundrygate/onboarding.py +++ b/foundrygate/onboarding.py @@ -48,6 +48,70 @@ def _provider_ready(provider: dict[str, Any]) -> tuple[bool, str]: return True, "configured" +def _build_provider_rollout( + providers: list[dict[str, Any]], + fallback_chain: list[str], +) -> dict[str, Any]: + """Return a staged rollout view for many-provider setups.""" + primary_stage: list[str] = [] + secondary_stage: list[str] = [] + modality_stage: list[str] = [] + + provider_index = {provider["name"]: provider for provider in providers} + fallback_status: list[dict[str, Any]] = [] + + for provider in providers: + if not provider["ready"]: + continue + + capabilities = provider.get("capabilities") or {} + is_image_capable = capabilities.get("image_generation") or capabilities.get( + "image_editing" + ) + if provider.get("contract") == "image-provider" or is_image_capable: + modality_stage.append(provider["name"]) + continue + + if provider.get("tier") in {"local", "default"}: + primary_stage.append(provider["name"]) + else: + secondary_stage.append(provider["name"]) + + ready_fallback_targets = 0 + for name in fallback_chain: + provider = provider_index.get(name) + is_ready = bool(provider and provider["ready"]) + if is_ready: + ready_fallback_targets += 1 + fallback_status.append( + { + "name": name, + "configured": provider is not None, + "ready": is_ready, + } + ) + + gaps: list[str] = [] + if providers and not primary_stage: + gaps.append("No ready primary provider is available for the first rollout stage.") + if len(providers) > 1 and fallback_chain and ready_fallback_targets == 0: + gaps.append("Fallback chain is configured, but none of its targets are currently ready.") + if any( + (provider.get("capabilities") or {}).get("image_generation") + or (provider.get("capabilities") or {}).get("image_editing") + for provider in providers + ) and not modality_stage: + gaps.append("Image-capable providers are configured, but none are ready yet.") + + return { + "stage_1_primary": primary_stage, + "stage_2_secondary": secondary_stage, + "stage_3_modality": modality_stage, + "fallback_targets": fallback_status, + "gaps": gaps, + } + + def build_onboarding_report( *, config_path: str | Path | None = None, @@ -110,6 +174,7 @@ def build_onboarding_report( suggestions.append("Set a fallback_chain before onboarding multiple clients.") if update_check.get("enabled") and not auto_update.get("enabled"): suggestions.append("Keep auto_update disabled until the provider and client set is stable.") + provider_rollout = _build_provider_rollout(providers, list(config.fallback_chain)) enabled_presets = set(client_profiles.get("presets", [])) profile_names = set(client_profiles.get("profiles", {}).keys()) @@ -188,6 +253,7 @@ def build_onboarding_report( "request_hooks_enabled": bool(request_hooks.get("enabled")), "request_hook_count": len(request_hooks.get("hooks", [])), }, + "provider_rollout": provider_rollout, "operations": { "update_checks_enabled": bool(update_check.get("enabled")), "auto_update_enabled": bool(auto_update.get("enabled")), @@ -203,6 +269,7 @@ def build_onboarding_validation(report: dict[str, Any]) -> dict[str, Any]: providers = report["providers"] clients = report["clients"] routing = report["routing"] + provider_rollout = report["provider_rollout"] env = report["env"] blockers: list[str] = [] @@ -217,12 +284,17 @@ def build_onboarding_validation(report: dict[str, Any]) -> dict[str, Any]: if providers["total"] > 1 and not routing["fallback_chain"]: blockers.append("Fallback chain is empty for a multi-provider setup.") + if providers["total"] > 1 and not provider_rollout["stage_1_primary"]: + blockers.append( + "No ready primary provider is available for a staged multi-provider rollout." + ) if providers["not_ready"] > 0: warnings.append( f"{providers['not_ready']} provider(s) are not ready: " + ", ".join(item["name"] for item in providers["items"] if not item["ready"]) ) + warnings.extend(provider_rollout["gaps"]) if not clients["profiles_enabled"]: warnings.append("Client profiles are disabled.") @@ -243,6 +315,7 @@ def render_onboarding_report(report: dict[str, Any]) -> str: provider_block = report["providers"] client_block = report["clients"] routing_block = report["routing"] + rollout_block = report["provider_rollout"] ops_block = report["operations"] integration_block = report["integrations"] preset_text = ", ".join(client_block["presets"]) if client_block["presets"] else "none" @@ -293,6 +366,14 @@ def render_onboarding_report(report: dict[str, Any]) -> str: f"- request hooks: {routing_block['request_hooks_enabled']} " f"({routing_block['request_hook_count']} hooks)", "", + "Provider rollout", + "- stage 1 primary: " + + (", ".join(rollout_block["stage_1_primary"]) or "none"), + "- stage 2 secondary: " + + (", ".join(rollout_block["stage_2_secondary"]) or "none"), + "- stage 3 modality: " + + (", ".join(rollout_block["stage_3_modality"]) or "none"), + "", "Operations", f"- update checks: {ops_block['update_checks_enabled']}", f"- auto update: {ops_block['auto_update_enabled']}", @@ -300,6 +381,12 @@ def render_onboarding_report(report: dict[str, Any]) -> str: ] ) + if rollout_block["fallback_targets"]: + lines.append("- fallback targets:") + for item in rollout_block["fallback_targets"]: + readiness = "ready" if item["ready"] else "not ready" + lines.append(f" - {item['name']}: {readiness}") + lines.extend(["", "Integration quickstarts"]) for client_name, data in integration_block.items(): readiness = "ready" if data["recommended"] else "needs preset or custom profile" diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 8532f29..f79f3dd 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -106,7 +106,9 @@ def test_onboarding_report_marks_local_worker_ready(tmp_path: Path): assert report["providers"]["ready"] == 1 assert report["providers"]["local_workers"] == 1 + assert report["provider_rollout"]["stage_1_primary"] == ["local-worker"] assert "local-worker: local-worker / openai-compat / local / ready" in text + assert "- stage 1 primary: local-worker" in text assert "Integration quickstarts" in text assert "header: X-FoundryGate-Client: codex" in text @@ -161,6 +163,10 @@ def test_onboarding_validation_blocks_missing_env_and_unready_providers( assert "Environment file is missing." in validation["blockers"] assert "No configured provider is ready." in validation["blockers"] assert "Fallback chain is empty for a multi-provider setup." in validation["blockers"] + assert ( + "No ready primary provider is available for a staged multi-provider rollout." + in validation["blockers"] + ) assert "Client profiles are disabled." in validation["warnings"] assert "Request hooks are enabled but no hooks are configured." in validation["warnings"] assert "Status: blocked" in text @@ -252,3 +258,71 @@ def test_onboarding_report_marks_all_builtin_integrations_ready(tmp_path: Path): assert report["integrations"]["openclaw"]["recommended"] is True assert report["integrations"]["n8n"]["recommended"] is True assert report["integrations"]["cli"]["recommended"] is True + + +def test_onboarding_report_includes_provider_rollout_stages_and_gaps(tmp_path: Path): + env_file = tmp_path / ".env" + env_file.write_text("PRIMARY_KEY=sk-primary\n", encoding="utf-8") + + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ +fallback_chain: + - image-worker +providers: + primary-chat: + backend: openai-compat + base_url: "https://api.example.com/v1" + api_key: "${PRIMARY_KEY}" + model: "primary-chat" + tier: default + image-worker: + contract: image-provider + backend: openai-compat + base_url: "http://127.0.0.1:9000/v1" + api_key: "${IMAGE_KEY}" + model: "image-model" + tier: specialty + capabilities: + image_generation: true +client_profiles: + enabled: true + default: generic + presets: ["openclaw", "n8n"] + profiles: + generic: {} + rules: [] +routing_policies: + enabled: false + rules: [] +request_hooks: + enabled: false + hooks: [] +update_check: + enabled: false +auto_update: + enabled: false +""".strip(), + encoding="utf-8", + ) + + report = build_onboarding_report(config_path=config_file, env_file=env_file) + validation = build_onboarding_validation(report) + text = render_onboarding_report(report) + + assert report["provider_rollout"]["stage_1_primary"] == ["primary-chat"] + assert report["provider_rollout"]["stage_2_secondary"] == [] + assert report["provider_rollout"]["stage_3_modality"] == [] + assert report["provider_rollout"]["fallback_targets"] == [ + {"name": "image-worker", "configured": True, "ready": False} + ] + assert ( + "Image-capable providers are configured, but none are ready yet." + in report["provider_rollout"]["gaps"] + ) + assert ( + "Fallback chain is configured, but none of its targets are currently ready." + in validation["warnings"] + ) + assert "- stage 1 primary: primary-chat" in text + assert "- fallback targets:" in text From 03813873421f49119ff7f03aa92b3ce0c37b53e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Sun, 15 Mar 2026 01:58:25 +0100 Subject: [PATCH 2/2] style(onboarding): apply ruff formatting --- foundrygate/onboarding.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/foundrygate/onboarding.py b/foundrygate/onboarding.py index bc03477..75d9b1c 100644 --- a/foundrygate/onboarding.py +++ b/foundrygate/onboarding.py @@ -65,9 +65,7 @@ def _build_provider_rollout( continue capabilities = provider.get("capabilities") or {} - is_image_capable = capabilities.get("image_generation") or capabilities.get( - "image_editing" - ) + is_image_capable = capabilities.get("image_generation") or capabilities.get("image_editing") if provider.get("contract") == "image-provider" or is_image_capable: modality_stage.append(provider["name"]) continue @@ -96,11 +94,14 @@ def _build_provider_rollout( gaps.append("No ready primary provider is available for the first rollout stage.") if len(providers) > 1 and fallback_chain and ready_fallback_targets == 0: gaps.append("Fallback chain is configured, but none of its targets are currently ready.") - if any( - (provider.get("capabilities") or {}).get("image_generation") - or (provider.get("capabilities") or {}).get("image_editing") - for provider in providers - ) and not modality_stage: + if ( + any( + (provider.get("capabilities") or {}).get("image_generation") + or (provider.get("capabilities") or {}).get("image_editing") + for provider in providers + ) + and not modality_stage + ): gaps.append("Image-capable providers are configured, but none are ready yet.") return { @@ -367,12 +368,9 @@ def render_onboarding_report(report: dict[str, Any]) -> str: f"({routing_block['request_hook_count']} hooks)", "", "Provider rollout", - "- stage 1 primary: " - + (", ".join(rollout_block["stage_1_primary"]) or "none"), - "- stage 2 secondary: " - + (", ".join(rollout_block["stage_2_secondary"]) or "none"), - "- stage 3 modality: " - + (", ".join(rollout_block["stage_3_modality"]) or "none"), + "- stage 1 primary: " + (", ".join(rollout_block["stage_1_primary"]) or "none"), + "- stage 2 secondary: " + (", ".join(rollout_block["stage_2_secondary"]) or "none"), + "- stage 3 modality: " + (", ".join(rollout_block["stage_3_modality"]) or "none"), "", "Operations", f"- update checks: {ops_block['update_checks_enabled']}",