Skip to content
Closed
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
54 changes: 29 additions & 25 deletions lib/fleet_dispatcher.ex
Original file line number Diff line number Diff line change
Expand Up @@ -75,20 +75,22 @@ defmodule Hypatia.FleetDispatcher do
})
end

# 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)
@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)
"""
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", ""))
Expand Down Expand Up @@ -152,17 +154,19 @@ defmodule Hypatia.FleetDispatcher do
end
end

# 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)
@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)
"""
def dispatch_routed_action({:dependabot_fix, recipe, pattern}) do
tier = Map.get(recipe, "triangle_tier", "control")
confidence = Map.get(recipe, "confidence", 0.5)
Expand Down
76 changes: 43 additions & 33 deletions lib/hypatia/web/api_router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,11 @@ defmodule Hypatia.Web.ApiRouter do
end
end

# 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.
@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 "/recipes/:id" do
health = Hypatia.OutcomeTracker.recipe_health()
row = Enum.find(health, &(&1.recipe_id == id))
Expand All @@ -79,9 +81,11 @@ defmodule Hypatia.Web.ApiRouter do
end
end

# GET /api/quarantine -- everything currently auto-quarantined:
# recipes (verification-rate gate) and bots (consecutive-failure /
# FP-rate gate from Hypatia.Safety.Quarantine).
@doc """
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()
Expand All @@ -99,9 +103,11 @@ defmodule Hypatia.Web.ApiRouter do
})
end

# 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.
@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 "/alerts" do
rows =
case Process.whereis(Hypatia.Watcher.Alerts) do
Expand All @@ -112,18 +118,20 @@ defmodule Hypatia.Web.ApiRouter do
json(conn, 200, %{count: length(rows), rows: rows})
end

# 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 = <peer hostname or "unknown">` so the
# Peer sink can skip it on broadcast and the dashboard can
# attribute it.
@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 = <peer hostname or "unknown">` 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)

Expand Down Expand Up @@ -166,18 +174,20 @@ defmodule Hypatia.Web.ApiRouter do

defp parse_atom(_), do: :unknown

# 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).
@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 "/events" do
conn = Plug.Conn.fetch_query_params(conn)
filter = parse_event_filter(conn.query_params["events"])
Expand Down
60 changes: 35 additions & 25 deletions lib/hypatia/web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,20 @@ defmodule Hypatia.Web.Router do
plug(:match)
plug(:dispatch)

# 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.
@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 "/" do
Hypatia.Web.Dashboard.call(conn, [])
end

# GET /health -- Basic health check for the HTTP endpoint.
@doc """
GET /health -- Basic health check for the HTTP endpoint.
"""
get "/health" do
health = %{
status: "ok",
Expand All @@ -50,23 +54,27 @@ defmodule Hypatia.Web.Router do
|> send_resp(200, Jason.encode!(health))
end

# 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.
@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" do
Hypatia.Web.Metrics.call(conn, [])
end

# 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.
@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" do
snapshot = Hypatia.Web.MetricsSnapshot.build()

Expand All @@ -80,12 +88,14 @@ defmodule Hypatia.Web.Router do
# reachable for container orchestrators.
forward("/api", to: Hypatia.Web.ApiRouter)

# 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.
@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" do
Hypatia.Web.GraphQL.call(conn, [])
end
Expand Down
Loading