#!/usr/bin/env bash
#
# ci-notify.sh — push the notify step's supplied needs results to agents over
# the SPT_DEV subnet. This is not the run's conclusion; check the run itself.
#
# ============================================================================
# NEW RUNNER CHECKLIST
# ============================================================================
# Adding a runner needs NOTHING in this repo. The two prerequisites are:
#
#   1. `spt` on the runner user's PATH.
#   2. The runner's node joined to the subnet the recipients live on.
#
# That is the entire clone story. There is no per-runner config file, no
# secret, no label mapping — the informant's identity is derived from
# $RUNNER_NAME at run time (see FROM_LABEL below).
#
# The notify job pins `runs-on: [self-hosted, Linux]` — the OS label, NOT a box
# name — so a new LINUX runner becomes eligible to host it automatically. A new
# WINDOWS runner contributes jobs and simply does not host the notify job; that
# is deliberate, not an oversight: ci.yml records (see its "Two shells" note)
# that the Windows runner has no bash on PATH, and no interpreter is present on
# both OSes. Notifying does not need to happen on the box that failed — this
# script reads job results out of the workflow context, not off the local disk.
#
# Consequence worth knowing: if every Linux runner is offline, that run goes
# unannounced. Silence from this informant is therefore ambiguous by
# construction; it is a convenience layer over polling, never a replacement for
# a receiver's own check.
# ============================================================================
#
# POSTURE: best-effort IN TIME as well as in exit code, always exit 0. A dead
# daemon, a missing `spt`, an unreachable recipient, a WEDGED send should not red
# a CI run. This step reports only its supplied needs results, not its own job's
# outcome or the run's conclusion: both are still unobserved when it sends.
#
# The time half is not theoretical (2026-07-27, this script's debut run and its
# rerun, both on kitsubito): the script ran correctly, computed its verdict, and
# then `spt send` HUNG — twice, ~5m each, same shape — until the notify job's own
# 5-minute budget cancelled it, and THAT cancellation is what reddened the run.
# `|| true` cannot guard a hang. The recipient was live-listening, so the send took
# the LIVE cross-node path (kitsubito -> bigscreen); an unbounded dial there is a
# product finding of its own (banked separately for ranking — every agent that
# scripts `spt send` inherits the same exposure). The send bound limits that
# exposure; it does not turn this step into evidence of a completed run.
#
# SPT_HOME: deliberately neither set NOR unset. Whatever the runner service's
# environment carries IS the runner user's real home, and that is the one the
# recipients' perches live under. No workflow in this repo exports SPT_HOME, and
# a separate job cannot inherit another job's env anyway, so a CI test-home
# cannot leak in here. The resolved value is echoed below so a future leak is
# visible in the log rather than silent.

set -uo pipefail

# --- inputs ----------------------------------------------------------------
# Job results arrive as RESULT_* env vars from the workflow step (each is one
# of: success | failure | cancelled | skipped | "" when the job never ran).
: "${RESULT_CHANGES:=}"
: "${RESULT_TRACEABILITY:=}"
: "${RESULT_TEST:=}"
: "${RESULT_N1_GATE:=}"
: "${RESULT_TWOHOST_A:=}"
: "${RESULT_TWOHOST_B:=}"
: "${PR_NUMBER:=}"
: "${RUNNER_NAME:=unknown}"
: "${GITHUB_REPOSITORY:=}"
: "${GITHUB_RUN_ID:=}"
: "${GITHUB_SERVER_URL:=https://github.com}"
: "${GITHUB_REF_NAME:=}"
: "${GITHUB_SHA:=}"

# --- verdict ---------------------------------------------------------------
# THREE classes, not two. `skipped` is NOT red: the docs-only thin gate
# (REQ-CI-DOCS-ONLY-THIN) deliberately skips `test` and `n1-gate` on a
# Markdown-only PR, so treating skipped as red would cry wolf on every docs PR.
#
# PRECEDENCE IS LOAD-BEARING, and getting it backwards silences real reds. A
# genuine failure normally CANCELS its siblings, so mixed failure+cancelled is
# the SHAPE OF A REAL RED, not of a cancellation:
#
#   any `failure`                  -> FAILURE   (cancelled siblings listed as context)
#   zero failures AND >=1 cancelled -> CANCELLED (its own class, never worded FAILURE)
#   otherwise                      -> SUCCESS
#
# The CANCELLED wording deliberately contains no occurrence of the string
# FAILURE: a human skimming the notification decides whether to drop what they
# are doing off that first word, and an operator-cancelled run is not a red.
# `ci-notify-selftest.sh` asserts the whole matrix, INCLUDING the positive
# control that a real failure still pages — a split that only proves
# "cancelled goes quiet" would pass just as happily if it silenced everything.
# [impl->REQ-CI-NOTIFY-CANCELLED-CLASS]
failing=""
cancelled=""

check() {
  case "$2" in
    failure) failing="${failing:+$failing, }$1($2)" ;;
    cancelled) cancelled="${cancelled:+$cancelled, }$1" ;;
  esac
}

# Sets `verdict`, `failing` and `cancelled` from the RESULT_* environment. Reads
# them at CALL time (not through the `:=` defaults above) so a caller — the
# selftest — can drive the matrix by assignment and call this repeatedly.
compute_verdict() {
  failing=""
  cancelled=""
  check changes "${RESULT_CHANGES:-}"
  check traceability "${RESULT_TRACEABILITY:-}"
  check test "${RESULT_TEST:-}"
  check n1-gate "${RESULT_N1_GATE:-}"
  check twohost-a "${RESULT_TWOHOST_A:-}"
  check twohost-b "${RESULT_TWOHOST_B:-}"

  if [ -n "$failing" ]; then
    verdict="FAILURE"
  elif [ -n "$cancelled" ]; then
    verdict="CANCELLED"
  else
    verdict="SUCCESS"
  fi
}

# Writes the notification body to stdout. Split out from the send path for the
# same reason `compute_verdict` is: the body's wording is part of the contract
# (see the CANCELLED note above), so the selftest has to be able to read it
# without a daemon, a subnet or a recipient.
# IR-40 arm B: even a correct needs verdict cannot cover the notifying job.
# [impl->REQ-CI-NOTIFY-SCOPE]
compose_body() {
  echo "CI NEEDS $verdict — ${GITHUB_REPOSITORY##*/} @ ${GITHUB_REF_NAME:-<unknown ref>}"
  echo "scope: notify step in job ${GITHUB_JOB:-<unknown job>}; supplied needs results only (notifying job excluded)"
  echo "run conclusion: not observed — check the run itself"
  [ -n "$PR_NUMBER" ] && echo "PR: #$PR_NUMBER"
  echo "sha: $(printf '%.12s' "$GITHUB_SHA")"
  [ -n "$failing" ] && echo "failing: $failing"
  [ -n "$cancelled" ] && echo "cancelled: $cancelled"
  echo "run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
  return 0
}

# --- library seam ----------------------------------------------------------
# Sourced with SPT_CI_NOTIFY_LIB=1, this file defines the classification and
# body-composition functions and stops — no `spt` probe, no daemon probe, no
# recipients, no sends. Same seam as `reap-census.sh`'s SPT_CI_REAP_LIB, and for
# the same reason: below this line the script's first act on a runner without
# `spt` is `exit 0`, and on a runner WITH it the next act is a real send, so the
# verdict logic is untestable in place. `return` when sourced, and refuse to be
# EXECUTED with the flag set — that combination means the caller thinks it is
# sourcing and would otherwise get a silent exit-0 no-op.
if [ "${SPT_CI_NOTIFY_LIB:-}" = "1" ]; then
  (return 0 2>/dev/null) && return 0
  echo "ci-notify: SPT_CI_NOTIFY_LIB=1 is for SOURCING this file, not executing it" >&2
  exit 2
fi

echo "ci-notify: SPT_HOME=${SPT_HOME:-<unset — spt resolves its default>}"

if ! command -v spt >/dev/null 2>&1; then
  echo "ci-notify: no spt on PATH for this runner user — nothing to notify with, skipping"
  exit 0
fi

# Diagnostic only, never a gate: `spt send` cannot autostart the daemon (cmd_send
# guards on is_running and the send path holds no ensure_running call), and a send
# with the daemon DOWN still spools — it drains when the recipient next listens,
# which is the behaviour we want. So a down daemon is logged, not skipped.
if spt daemon status >/dev/null 2>&1; then
  echo "ci-notify: daemon is up — sends deliver live where a recipient is listening"
else
  echo "ci-notify: daemon is down — sends will spool and drain when the recipient listens"
fi

compute_verdict

# --- body ------------------------------------------------------------------
# Composed to a FILE, never passed inline: a body with quotes, newlines or a
# stray `$` in a commit subject would otherwise be a shell-quoting trap.
body_file="$(mktemp)"
trap 'rm -f "$body_file"' EXIT
compose_body >"$body_file"

echo "ci-notify: supplied needs verdict $verdict${failing:+ (failing: $failing)}${cancelled:+ (cancelled: $cancelled)} — not a run conclusion"

# --- recipients ------------------------------------------------------------
# doyle always (he gates and merges). Plus a best-effort trailer parse off the
# head commit: this project's commits end `Co-authored by: <agent>` — note the
# SPACE. That spelling is this repo's agent-name convention (CLAUDE.md), and it
# is the only one matched on purpose: git's standard hyphenated
# `Co-authored-by: Some Human <addr>` names a person or a tool, not a perch, so
# matching it would mint junk recipients (a real commit on this branch's history
# yields "OpenAICodex" that way). IR-47: absent or unparseable stays non-fatal,
# but warns explicitly; it is not the same as a co-author who is already doyle.
FROM_LABEL="CI-$(printf '%s' "$RUNNER_NAME" | tr '[:lower:]' '[:upper:]' | tr -cs '[:alnum:]' '-')"

# [impl->REQ-CI-NOTIFY-ATTRIBUTION]
recipients="doyle"
trailer="$(git log -1 --pretty=%B |
  sed -n 's/^Co-authored by:[[:space:]]*\([^<]*\).*$/\1/Ip' |
  tail -n 1 |
  sed 's/[[:space:]]*$//; s/^[[:space:]]*//')"
trailer_rc=$?
if [ "$trailer_rc" -ne 0 ]; then
  echo "::warning title=CI notification attribution missing::Could not read or parse head commit (rc=$trailer_rc) for 'Co-authored by: <agent>' — notifying doyle only"
elif [ -z "$trailer" ]; then
  echo "::warning title=CI notification attribution missing::Head commit has no parseable line-anchored 'Co-authored by: <agent>' trailer — notifying doyle only"
elif [ "$trailer" = "doyle" ]; then
  echo "ci-notify: co-author is doyle — no second recipient"
else
  recipients="$recipients $trailer"
fi

# --- send ------------------------------------------------------------------
# DEFAULT window on purpose: this message SHOULD wake an idle recipient — being
# told the supplied gates finished is the entire point. Never --active-only, never
# --idle-only.
#
# QUEUED is SUCCESS: it means the recipient was not listening at that instant
# (busy or offline), so the message spooled and drains next time they listen.
# Only a hard CLI error is a miss, and even that is non-fatal here.
# Per-recipient wall-clock bound. 30s is generous for the intended act (a local
# broker hand-off, or a cross-node dial to a live peer) and small against the notify
# job's 5-minute budget, so a wedged send can no longer approach it. The job's
# `timeout-minutes` deliberately STAYS 5: raising it would hide this class instead of
# bounding it.
SEND_BOUND_SECS=30

# Same-node hop: a send whose CLI runs on the RECIPIENTS' node is a local broker
# hand-off — the cross-node relay leg that produced every NOTIFY_SEND_TIMEOUT so
# far (5 sightings, one delivery in six) is simply not on the path. So when the
# hop is reachable, run `spt send` THERE over ssh; the direct cross-node send
# stays as the fallback, and both legs keep the same wall-clock bound. Empty
# NOTIFY_SSH_HOP disables the hop entirely.
NOTIFY_SSH_HOP="${NOTIFY_SSH_HOP-decid@hfenduleam}"

send_to() {
  # $1 = recipient. Echoes the winning leg's output; return code is the send's.
  if [ -n "$NOTIFY_SSH_HOP" ] && command -v ssh >/dev/null 2>&1; then
    hop_out="$(timeout "$SEND_BOUND_SECS" ssh -o BatchMode=yes -o ConnectTimeout=5 \
      -o StrictHostKeyChecking=accept-new "$NOTIFY_SSH_HOP" \
      "spt send $1 --from $FROM_LABEL" <"$body_file" 2>&1)"
    hop_rc=$?
    if [ "$hop_rc" -eq 0 ]; then
      printf '%s' "$hop_out (via $NOTIFY_SSH_HOP)"
      return 0
    fi
    # Loud, then fall through: hop failure must be visible even when the
    # fallback succeeds, or a rotted hop silently reverts us to the hang class.
    echo "ci-notify: HOP_FAILED rc=$hop_rc via $NOTIFY_SSH_HOP — falling back to direct send" >&2
  fi
  timeout "$SEND_BOUND_SECS" spt send "$1" --from "$FROM_LABEL" <"$body_file" 2>&1
}

for to in $recipients; do
  out="$(send_to "$to")"
  rc=$?
  case "$rc" in
    0)
      echo "ci-notify: -> $to: ${out:-<no output>}"
      ;;
    124)
      # coreutils `timeout` exit code for "killed on the deadline". LOUD ON PURPOSE:
      # this informant exists so that silence stops being ambiguous, so its OWN
      # failure must never be silent — an operator reading the log has to be able to
      # tell "nobody was told" from "nothing happened".
      echo "ci-notify: NOTIFY_SEND_TIMEOUT -> $to: no completion within ${SEND_BOUND_SECS}s — send ABANDONED, recipient NOT notified (CI unaffected)"
      ;;
    *)
      echo "ci-notify: -> $to: SEND FAILED rc=$rc: ${out:-<no output>}"
      ;;
  esac
done

exit 0
