diff --git a/lib/fleet_dispatcher.ex b/lib/fleet_dispatcher.ex index c7cc1f07..363872a6 100644 --- a/lib/fleet_dispatcher.ex +++ b/lib/fleet_dispatcher.ex @@ -45,54 +45,6 @@ defmodule Hypatia.FleetDispatcher do end end - # Standard fleet dispatch path for eliminate tier. - defp dispatch_eliminate_via_fleet(recipe, pattern) do - confidence = Map.get(recipe, "confidence", 0.0) - strategy = TriangleRouter.dispatch_strategy(confidence) - - bot_id = - case strategy do - :auto_execute -> "robot-repo-automaton" - :review -> "rhodibot" - :report_only -> "sustainabot" - end - - action_type = - case strategy do - :auto_execute -> :commit_push - :review -> :pr_create - :report_only -> :advisory - end - - # Gate review -- every action must pass through the Kin Gate - gate_action = %{ - bot_id: bot_id, - repo: get_pattern_repo(pattern), - action_type: action_type, - confidence: confidence, - pattern_id: Map.get(pattern, "id", Map.get(pattern, "description", "")), - scan_timestamp: Map.get(pattern, "scan_timestamp"), - dispatch_tier: strategy - } - - case gate_review(gate_action) do - {:approved, _} -> - do_eliminate_dispatch(strategy, recipe, pattern, confidence) - - {:held, reason} -> - Logger.warning("Gate held eliminate dispatch: #{reason}") - {:ok, :held} - - {:rejected, reason} -> - Logger.warning("Gate rejected eliminate dispatch: #{reason}") - {:error, :gate_rejected, reason} - - {:deferred, wait_ms} -> - Logger.info("Gate deferred eliminate dispatch -- retry in #{div(wait_ms, 1000)}s") - {:ok, :deferred} - end - end - def dispatch_routed_action({:substitute, recipe, pattern}) do proven_module = Map.get(recipe, "proven_module", "unknown") @@ -123,22 +75,20 @@ defmodule Hypatia.FleetDispatcher do }) end - @doc """ - Dispatch a ProofObligation recipe through the Safety Triangle. - - Called by `ProofObligation.obligations_from_patterns/2` and any code - that constructs `{:proof_obligation, recipe, pattern}` tuples. - - Triangle routing for proof obligations: - - `:eliminate` (auto-provable, confidence >= 0.90) → - robot-repo-automaton applies tactic inline - - `:eliminate` (confidence < 0.90) → - echidnabot with eliminate-tier hint - - `:substitute` → - echidnabot with VeriSimDB-recommended prover hint - - `:control` → - sustainabot advisory (sorry/Admitted present, human required) - """ + # Dispatch a ProofObligation recipe through the Safety Triangle. + # + # Called by `ProofObligation.obligations_from_patterns/2` and any code + # that constructs `{:proof_obligation, recipe, pattern}` tuples. + # + # Triangle routing for proof obligations: + # - `:eliminate` (auto-provable, confidence >= 0.90) → + # robot-repo-automaton applies tactic inline + # - `:eliminate` (confidence < 0.90) → + # echidnabot with eliminate-tier hint + # - `:substitute` → + # echidnabot with VeriSimDB-recommended prover hint + # - `:control` → + # sustainabot advisory (sorry/Admitted present, human required) def dispatch_routed_action({:proof_obligation, recipe, pattern}) do tier = Map.get(recipe, "triangle_tier", "substitute") claim = Map.get(recipe, "claim", Map.get(pattern, "description", "")) @@ -202,19 +152,17 @@ defmodule Hypatia.FleetDispatcher do end end - @doc """ - Dispatch a DependabotAlerts recipe through the Safety Triangle. - - Called by `DependabotAlerts.fixes_from_alerts/3` and any code that - constructs `{:dependabot_fix, recipe, pattern}` tuples. - - Triangle routing for Dependabot alerts: - - `:eliminate` + confidence >= 0.95 -> robot-repo-automaton auto-bumps - (subject to Kin Gate, rate limiter, exclusion registry) - - `:eliminate` + confidence in [0.85, 0.95) -> rhodibot opens a PR - - `:substitute` -> rhodibot opens a PR (major bump / breaking change) - - `:control` -> sustainabot advisory (no auto-fix path) - """ + # Dispatch a DependabotAlerts recipe through the Safety Triangle. + # + # Called by `DependabotAlerts.fixes_from_alerts/3` and any code that + # constructs `{:dependabot_fix, recipe, pattern}` tuples. + # + # Triangle routing for Dependabot alerts: + # - `:eliminate` + confidence >= 0.95 -> robot-repo-automaton auto-bumps + # (subject to Kin Gate, rate limiter, exclusion registry) + # - `:eliminate` + confidence in [0.85, 0.95) -> rhodibot opens a PR + # - `:substitute` -> rhodibot opens a PR (major bump / breaking change) + # - `:control` -> sustainabot advisory (no auto-fix path) def dispatch_routed_action({:dependabot_fix, recipe, pattern}) do tier = Map.get(recipe, "triangle_tier", "control") confidence = Map.get(recipe, "confidence", 0.5) @@ -265,6 +213,54 @@ defmodule Hypatia.FleetDispatcher do end end + # Standard fleet dispatch path for eliminate tier. + defp dispatch_eliminate_via_fleet(recipe, pattern) do + confidence = Map.get(recipe, "confidence", 0.0) + strategy = TriangleRouter.dispatch_strategy(confidence) + + bot_id = + case strategy do + :auto_execute -> "robot-repo-automaton" + :review -> "rhodibot" + :report_only -> "sustainabot" + end + + action_type = + case strategy do + :auto_execute -> :commit_push + :review -> :pr_create + :report_only -> :advisory + end + + # Gate review -- every action must pass through the Kin Gate + gate_action = %{ + bot_id: bot_id, + repo: get_pattern_repo(pattern), + action_type: action_type, + confidence: confidence, + pattern_id: Map.get(pattern, "id", Map.get(pattern, "description", "")), + scan_timestamp: Map.get(pattern, "scan_timestamp"), + dispatch_tier: strategy + } + + case gate_review(gate_action) do + {:approved, _} -> + do_eliminate_dispatch(strategy, recipe, pattern, confidence) + + {:held, reason} -> + Logger.warning("Gate held eliminate dispatch: #{reason}") + {:ok, :held} + + {:rejected, reason} -> + Logger.warning("Gate rejected eliminate dispatch: #{reason}") + {:error, :gate_rejected, reason} + + {:deferred, wait_ms} -> + Logger.info("Gate deferred eliminate dispatch -- retry in #{div(wait_ms, 1000)}s") + {:ok, :deferred} + end + end + defp maybe_cve(nil), do: "" defp maybe_cve(""), do: "" defp maybe_cve(cve), do: " (#{cve})" diff --git a/lib/hypatia/diagnostics/monitor.ex b/lib/hypatia/diagnostics/monitor.ex index b30667f9..f602ab53 100644 --- a/lib/hypatia/diagnostics/monitor.ex +++ b/lib/hypatia/diagnostics/monitor.ex @@ -158,12 +158,12 @@ defmodule Hypatia.Diagnostics.Monitor do nil -> {:error, :neural_unresponsive} status -> {:ok, {:neural, status}} end + rescue + _ -> {:error, :neural_crashed} catch # Training cycles can take minutes -- a timeout means busy, not crashed :exit, {:timeout, _} -> {:ok, {:neural, :training_in_progress}} :exit, _ -> {:error, :neural_crashed} - rescue - _ -> {:error, :neural_crashed} end end diff --git a/lib/hypatia/web/api_router.ex b/lib/hypatia/web/api_router.ex index f771c91f..1f67528e 100644 --- a/lib/hypatia/web/api_router.ex +++ b/lib/hypatia/web/api_router.ex @@ -64,11 +64,9 @@ defmodule Hypatia.Web.ApiRouter do end end - @doc """ - GET /api/recipes/:id -- single-recipe drill-down. Returns the same - shape as one row from `/api/recipes`, plus the recipe definition - itself when found in the registry. - """ + # GET /api/recipes/:id -- single-recipe drill-down. Returns the same + # shape as one row from `/api/recipes`, plus the recipe definition + # itself when found in the registry. get "/recipes/:id" do health = Hypatia.OutcomeTracker.recipe_health() row = Enum.find(health, &(&1.recipe_id == id)) @@ -81,11 +79,9 @@ defmodule Hypatia.Web.ApiRouter do end end - @doc """ - GET /api/quarantine -- everything currently auto-quarantined: - recipes (verification-rate gate) and bots (consecutive-failure / - FP-rate gate from Hypatia.Safety.Quarantine). - """ + # GET /api/quarantine -- everything currently auto-quarantined: + # recipes (verification-rate gate) and bots (consecutive-failure / + # FP-rate gate from Hypatia.Safety.Quarantine). get "/quarantine" do recipes = Hypatia.OutcomeTracker.recipe_health() @@ -103,11 +99,9 @@ defmodule Hypatia.Web.ApiRouter do }) end - @doc """ - GET /api/alerts -- Recent threshold-rule alerts emitted by - Hypatia.Watcher.Alerts (ring buffer, newest first). Powers the - dashboard alert ribbon and supports manual triage. - """ + # GET /api/alerts -- Recent threshold-rule alerts emitted by + # Hypatia.Watcher.Alerts (ring buffer, newest first). Powers the + # dashboard alert ribbon and supports manual triage. get "/alerts" do rows = case Process.whereis(Hypatia.Watcher.Alerts) do @@ -118,20 +112,18 @@ defmodule Hypatia.Web.ApiRouter do json(conn, 200, %{count: length(rows), rows: rows}) end - @doc """ - POST /api/alerts/ingest -- Federation ingress. Peer hypatia - instances POST their alerts here via the Peer sink. - - Auth: the auth_gate plug enforces a valid bearer token, so this - endpoint is only reachable when HYPATIA_API_BEARER_TOKEN is set - and the request carries it. Federation without shared auth is - refused at the gate, not here. - - Loop prevention: the ingested alert is tagged with - `metadata.federated_from = ` so the - Peer sink can skip it on broadcast and the dashboard can - attribute it. - """ + # POST /api/alerts/ingest -- Federation ingress. Peer hypatia + # instances POST their alerts here via the Peer sink. + # + # Auth: the auth_gate plug enforces a valid bearer token, so this + # endpoint is only reachable when HYPATIA_API_BEARER_TOKEN is set + # and the request carries it. Federation without shared auth is + # refused at the gate, not here. + # + # Loop prevention: the ingested alert is tagged with + # `metadata.federated_from = ` so the + # Peer sink can skip it on broadcast and the dashboard can + # attribute it. post "/alerts/ingest" do {:ok, body, conn} = Plug.Conn.read_body(conn) @@ -174,20 +166,18 @@ defmodule Hypatia.Web.ApiRouter do defp parse_atom(_), do: :unknown - @doc """ - GET /api/events -- Server-Sent Events stream of telemetry as it - fires. Each event arrives as - - event: hypatia.scan.complete - data: {"measurements": {...}, "metadata": {...}, "at": ms} - - Optional `?events=hypatia.scan.complete,hypatia.outcome.recorded` - filter narrows the stream to specific event kinds. - - Heartbeats every 15s as comment lines (`: keepalive`) defeat proxy - idle-timeouts. The handler exits cleanly when the client disconnects - (Bandit closes the chunked response). - """ + # GET /api/events -- Server-Sent Events stream of telemetry as it + # fires. Each event arrives as + # + # event: hypatia.scan.complete + # data: {"measurements": {...}, "metadata": {...}, "at": ms} + # + # Optional `?events=hypatia.scan.complete,hypatia.outcome.recorded` + # filter narrows the stream to specific event kinds. + # + # Heartbeats every 15s as comment lines (`: keepalive`) defeat proxy + # idle-timeouts. The handler exits cleanly when the client disconnects + # (Bandit closes the chunked response). get "/events" do conn = Plug.Conn.fetch_query_params(conn) filter = parse_event_filter(conn.query_params["events"]) @@ -368,7 +358,7 @@ defmodule Hypatia.Web.ApiRouter do a |> :binary.bin_to_list() |> Enum.zip(:binary.bin_to_list(b)) - |> Enum.reduce(0, fn {x, y}, acc -> acc ||| Bitwise.bxor(x, y) end) == 0 + |> Enum.reduce(0, fn {x, y}, acc -> acc ||| bxor(x, y) end) == 0 end end diff --git a/lib/hypatia/web/router.ex b/lib/hypatia/web/router.ex index c9b30eac..e2098bc9 100644 --- a/lib/hypatia/web/router.ex +++ b/lib/hypatia/web/router.ex @@ -28,20 +28,16 @@ defmodule Hypatia.Web.Router do plug(:match) plug(:dispatch) - @doc """ - GET / -- Single-page live operational dashboard. HTML + vanilla JS, - polls /api/status and EventSource-streams /api/events. The dashboard - itself is publicly reachable; the data endpoints it calls are - loopback-only (gated in ApiRouter), so a non-local browser would - render the chrome but get 403 from the XHR/SSE calls. - """ + # GET / -- Single-page live operational dashboard. HTML + vanilla JS, + # polls /api/status and EventSource-streams /api/events. The dashboard + # itself is publicly reachable; the data endpoints it calls are + # loopback-only (gated in ApiRouter), so a non-local browser would + # render the chrome but get 403 from the XHR/SSE calls. get "/" do Hypatia.Web.Dashboard.call(conn, []) end - @doc """ - GET /health -- Basic health check for the HTTP endpoint. - """ + # GET /health -- Basic health check for the HTTP endpoint. get "/health" do health = %{ status: "ok", @@ -54,27 +50,23 @@ defmodule Hypatia.Web.Router do |> send_resp(200, Jason.encode!(health)) end - @doc """ - GET /metrics -- Prometheus text-format exposition. Publicly - reachable (NOT loopback-only) because scrapers routinely run on a - different host; there's no operational data in the metric body - that isn't already implied by the dashboard's existence. - """ + # GET /metrics -- Prometheus text-format exposition. Publicly + # reachable (NOT loopback-only) because scrapers routinely run on a + # different host; there's no operational data in the metric body + # that isn't already implied by the dashboard's existence. get "/metrics" do Hypatia.Web.Metrics.call(conn, []) end - @doc """ - GET /metrics/snapshot -- Compact JSON snapshot of estate-level - counters: repos scanned, weak points, dispatched actions, outcomes, - recipes, average confidence. Consumed by the optional Ada TUI - (`lib/tui/port.ex`) on its 10s tick, and useful as a single-call - status read for external dashboards. - - Reads from the verisim-data flat-file store via VerisimConnector; - any failure returns a degraded snapshot with status="degraded" - rather than 500, so the TUI keeps rendering. - """ + # GET /metrics/snapshot -- Compact JSON snapshot of estate-level + # counters: repos scanned, weak points, dispatched actions, outcomes, + # recipes, average confidence. Consumed by the optional Ada TUI + # (`lib/tui/port.ex`) on its 10s tick, and useful as a single-call + # status read for external dashboards. + # + # Reads from the verisim-data flat-file store via VerisimConnector; + # any failure returns a degraded snapshot with status="degraded" + # rather than 500, so the TUI keeps rendering. get "/metrics/snapshot" do snapshot = Hypatia.Web.MetricsSnapshot.build() @@ -88,14 +80,12 @@ defmodule Hypatia.Web.Router do # reachable for container orchestrators. forward("/api", to: Hypatia.Web.ApiRouter) - @doc """ - POST /graphql -- GraphQL-shaped query endpoint (M14). - - Minimal hand-rolled implementation; no introspection, no schema - federation, no Absinthe dep. See lib/hypatia/web/graphql.ex for - the supported field set and limitations. Loopback-only by sharing - the bearer-auth gate when HYPATIA_API_BEARER_TOKEN is configured. - """ + # POST /graphql -- GraphQL-shaped query endpoint (M14). + # + # Minimal hand-rolled implementation; no introspection, no schema + # federation, no Absinthe dep. See lib/hypatia/web/graphql.ex for + # the supported field set and limitations. Loopback-only by sharing + # the bearer-auth gate when HYPATIA_API_BEARER_TOKEN is configured. post "/graphql" do Hypatia.Web.GraphQL.call(conn, []) end diff --git a/lib/merge_orchestration/strategist.ex b/lib/merge_orchestration/strategist.ex index e0ae384b..00c85d53 100644 --- a/lib/merge_orchestration/strategist.ex +++ b/lib/merge_orchestration/strategist.ex @@ -115,36 +115,55 @@ defmodule Hypatia.MergeOrchestration.Strategist do vetoes = [] # License/SPDX touch veto - if Map.get(ctx, :license_touch, false) do - vetoes = [%{bot: "policy-gate", reason: "license/SPDX -- owner-only"} | vetoes] - end + vetoes = + if Map.get(ctx, :license_touch, false) do + [%{bot: "policy-gate", reason: "license/SPDX -- owner-only"} | vetoes] + else + vetoes + end # DO NOT MERGE / WIP in title (case-insensitive) title = Map.get(ctx, :title, "") title_lower = String.downcase(title) - if String.contains?(title_lower, "do not merge") || String.contains?(title_lower, "wip") do - vetoes = [%{bot: "policy-gate", reason: "title contains DO NOT MERGE or WIP"} | vetoes] - end + + vetoes = + if String.contains?(title_lower, "do not merge") || String.contains?(title_lower, "wip") do + [%{bot: "policy-gate", reason: "title contains DO NOT MERGE or WIP"} | vetoes] + else + vetoes + end # do-not-merge / hold labels (case-insensitive) labels = Map.get(ctx, :labels, []) hold_labels = ["do-not-merge", "hold", "do not merge"] labels_lower = Enum.map(labels, &String.downcase/1) - if Enum.any?(labels_lower, &(&1 in hold_labels)) do - vetoes = [%{bot: "policy-gate", reason: "PR has do-not-merge or hold label"} | vetoes] - end + + vetoes = + if Enum.any?(labels_lower, &(&1 in hold_labels)) do + [%{bot: "policy-gate", reason: "PR has do-not-merge or hold label"} | vetoes] + else + vetoes + end # litmus/ or test/ branch prefix branch = Map.get(ctx, :branch, "") - if String.starts_with?(branch, "litmus/") || String.starts_with?(branch, "test/") do - vetoes = [%{bot: "policy-gate", reason: "branch prefix litmus/ or test/"} | vetoes] - end + + vetoes = + if String.starts_with?(branch, "litmus/") || String.starts_with?(branch, "test/") do + [%{bot: "policy-gate", reason: "branch prefix litmus/ or test/"} | vetoes] + else + vetoes + end # Draft state state = Map.get(ctx, :state, "open") - if state == "draft" do - vetoes = [%{bot: "policy-gate", reason: "PR is in draft state"} | vetoes] - end + + vetoes = + if state == "draft" do + [%{bot: "policy-gate", reason: "PR is in draft state"} | vetoes] + else + vetoes + end vetoes end diff --git a/lib/neural/prover_recommender.ex b/lib/neural/prover_recommender.ex index 8f5a6887..b41b9e70 100644 --- a/lib/neural/prover_recommender.ex +++ b/lib/neural/prover_recommender.ex @@ -120,7 +120,7 @@ defmodule Hypatia.Neural.ProverRecommender do # --- verisim-api bridge --------------------------------------------------- - defp fetch_attempts(limit, base_url \\ nil) do + defp fetch_attempts(limit, base_url) do resolved_url = base_url || @verisim_base_url url = "#{resolved_url}/api/v1/proof_attempts?limit=#{limit}" # verisim-api /proof_attempts GET doesn't exist yet -- fall back to ClickHouse @@ -131,7 +131,7 @@ defmodule Hypatia.Neural.ProverRecommender do end end - defp fetch_attempts_via_clickhouse(limit, base_url \\ nil) do + defp fetch_attempts_via_clickhouse(limit, base_url) do resolved_url = base_url || @verisim_base_url # ClickHouse HTTP: reach it by probing each active class's strategy endpoint # and folding the recommendations back into synthetic attempt rows. diff --git a/lib/rules/admin_merge_eligibility.ex b/lib/rules/admin_merge_eligibility.ex index 5d3a3a7a..cee487b4 100644 --- a/lib/rules/admin_merge_eligibility.ex +++ b/lib/rules/admin_merge_eligibility.ex @@ -253,7 +253,9 @@ defmodule Hypatia.Rules.AdminMergeEligibility do Returns `:stalled` if the heuristic fires, `:ok` otherwise. """ @spec dependabot_stalled?(map(), pos_integer()) :: :stalled | :ok - def dependabot_stalled?(%{author: %{login: "dependabot[bot]"}} = pr, days_threshold \\ 7) do + def dependabot_stalled?(pr, days_threshold \\ 7) + + def dependabot_stalled?(%{author: %{login: "dependabot[bot]"}} = pr, days_threshold) do created = Map.get(pr, :createdAt, "") has_review_requests = Map.get(pr, :reviewRequests, []) != [] @@ -360,7 +362,7 @@ defmodule Hypatia.Rules.AdminMergeEligibility do """ @spec obsolete_supersedes?(map(), (String.t() -> String.t() | nil)) :: {:obsolete, String.t()} | :not_obsolete - def obsolete_supersedes?(%{files: files} = pr, main_lookup) + def obsolete_supersedes?(%{files: files}, main_lookup) when is_function(main_lookup, 1) do # Look at workflow YAML edits that change a `uses: ...@` line. Enum.find_value(files, :not_obsolete, fn file -> diff --git a/lib/rules/rules.ex b/lib/rules/rules.ex index 6f5dfe5f..83d121b8 100644 --- a/lib/rules/rules.ex +++ b/lib/rules/rules.ex @@ -28,7 +28,6 @@ defmodule Hypatia.Rules do alias Hypatia.Rules.WorkflowHardening alias Hypatia.Rules.SupplyChain alias Hypatia.Rules.BranchProtection - alias Hypatia.Rules.AdminMergeEligibility # alias Hypatia.Rules.ResearchExtensions # wired in follow-up after PR #325 merges @doc """ diff --git a/lib/vcl/client.ex b/lib/vcl/client.ex index 9b7487de..7e14ff3a 100644 --- a/lib/vcl/client.ex +++ b/lib/vcl/client.ex @@ -123,14 +123,6 @@ defmodule Hypatia.VCL.Client do end end - # Route a parsed AST to the right executor. Multi-URL remote federation - # goes to RemoteExecutor; every other source stays on FileExecutor. - defp dispatch(%{source: {:federation_remote, urls, _policy}} = ast, opts) do - Hypatia.VCL.RemoteExecutor.execute(urls, ast, opts) - end - - defp dispatch(ast, opts), do: Hypatia.VCL.FileExecutor.execute(ast, opts) - @impl true def handle_call(:stats, _from, state) do {:reply, @@ -141,6 +133,14 @@ defmodule Hypatia.VCL.Client do }, state} end + # Route a parsed AST to the right executor. Multi-URL remote federation + # goes to RemoteExecutor; every other source stays on FileExecutor. + defp dispatch(%{source: {:federation_remote, urls, _policy}} = ast, opts) do + Hypatia.VCL.RemoteExecutor.execute(urls, ast, opts) + end + + defp dispatch(ast, opts), do: Hypatia.VCL.FileExecutor.execute(ast, opts) + # --------------------------------------------------------------------------- # Built-in VCL Parser (derived from VeriSim.Query.VQLBridge) # ---------------------------------------------------------------------------