#!/bin/bash
# ci-autorelease <verb> [arg]   -- message on stdin for `notify`
# CANONICAL SOURCE (SID-607). Exec'd unchanged by `agentforge ci autorelease`
# (src/agentforge/ci/autorelease.py) — the ROOT-owned copy at
# /usr/local/bin/ci-autorelease is a stable shim over that entry point, so
# every fix here reaches the box on the next `agentforge update apply`
# instead of waiting for someone to re-run install-ci-task-creation.sh (the
# gap that left the rollback fix, SID-599, un-deployed for weeks). Kept as
# bash rather than rewritten in Python: this file already carries several
# hard-won fixes (SID-231, SID-386, SID-388, SID-599) in its argv validation,
# its git orchestration and its exact error text, and moving it unchanged is
# far lower risk than re-deriving that behaviour from scratch.
#
# The autonomous release chain (SID-388) turned three human gestures into
# machine steps: cutting the candidate, and deploying what was published. Both
# need the `agentforge` user — its git credentials, its 0600 config tree, its
# systemd user units — and neither may be spelled out by the CI runner account.
# Same shape, same reasoning and the same single sudoers rule pattern as
# ci-release, which this joins rather than replaces.
#
# The runner may therefore say "cut a candidate" or "deploy 0.4.91" and nothing
# else: argv is validated here, never interpolated into a shell.
set -euo pipefail

ROOT=/home/agentforge/AgentForge
PY="$ROOT/runtime/.venv/bin/python"
AF="$ROOT/runtime/.venv/bin/agentforge"

# A checkout that belongs to `agentforge` and to this wrapper alone.
#
# Two clones already exist on the box and neither can be used. The Actions
# workspace belongs to the runner account, and git refuses to run in a tree it
# does not own (`dubious ownership`) — which is the point of the separation.
# `git-repos/AgentForge` belongs to agentforge but is live state: the agents
# leave it on their own branches with their own untracked files, and a release
# cut that resets it would destroy work in progress. So the chain gets its own,
# and treats it as disposable.
CHECKOUT="$ROOT/runtime/release-checkout"
ORIGIN=https://github.com/Martin-Rancourt/AgentForge.git

# Two release verbs, not one (SID-386). With the short-lived release branch,
# OPENING a cycle and putting the NEXT candidate on an open one are separate
# acts with separate preconditions, so they are separate verbs here too:
#
#   open   `cut-rc.sh --open` from `main` — creates release/<YYYY-MM-DD-HH-MM>-utc,
#          dispatches release-please on it, tags rc.1.
#   cut    `cut-rc.sh` from the release branch already in flight — rc.N+1
#          after a hotfix. Refuses unless exactly one such branch exists.
usage() {
  echo "usage: ci-autorelease <open [routine|sensible] | cut [routine|sensible] | deploy <version> | verify [version] | rollback | notify <topic>>" >&2
  exit 2
}

verb="${1:-}"; [ -n "$verb" ] || usage
arg="${2:-}"

case "$verb" in
  open|cut|deploy|verify|rollback|notify) ;;
  *) echo "invalid verb: $verb" >&2; usage ;;
esac

# One allowlist per verb. Nothing reaches a command line without matching a
# pattern here, so an argument cannot carry an option, a path or a shell
# metacharacter into what runs below.
case "$verb" in
  open|cut)
    case "$arg" in
      ""|routine|sensible) ;;
      *) echo "invalid release class: $arg (expected routine or sensible)" >&2; exit 2 ;;
    esac
    ;;
  deploy)
    [[ "$arg" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "invalid version: ${arg:-(missing)}" >&2; exit 2; }
    ;;
  verify)
    if [ -n "$arg" ]; then
      [[ "$arg" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "invalid version: $arg" >&2; exit 2; }
    fi
    ;;
  notify)
    # `agentforge` is the fleet's ops topic in bot/config.json; the agent names
    # are the per-agent threads. An unknown topic is a typo, not a new channel.
    case "$arg" in
      agentforge|architect|clawdia) ;;
      *) echo "invalid topic: ${arg:-(missing)}" >&2; exit 2 ;;
    esac
    ;;
esac

# ---------------------------------------------------------------------------

case "$verb" in

  open|cut)
    # A fresh tip, always. Anything left over from a previous run is noise at
    # best and a wrong candidate at worst.
    if [ ! -d "$CHECKOUT/.git" ]; then
      git clone --quiet "$ORIGIN" "$CHECKOUT"
    fi
    git -C "$CHECKOUT" fetch --quiet origin --prune --tags --force

    if [ "$verb" = open ]; then
      # Opening: from the tip of main. `cut-rc.sh --open` is what refuses when
      # another release branch is still in flight; nothing is decided here.
      git -C "$CHECKOUT" checkout --quiet -B main origin/main
      git -C "$CHECKOUT" reset --quiet --hard origin/main
    else
      # Cutting rc.N+1: on the ONE release branch in flight. Discovered from
      # the remote rather than passed in, because a branch name on the command
      # line is a free-text argument this wrapper exists not to accept — and
      # because "which cycle is open" is a fact of the repository, not an
      # opinion of the caller. Zero or several is not something to guess at.
      branches="$(git -C "$CHECKOUT" for-each-ref --format='%(refname:strip=3)' 'refs/remotes/origin/release/')"
      count="$(printf '%s\n' "$branches" | grep -c . || true)"
      if [ "$count" -ne 1 ]; then
        echo "ci-autorelease: expected exactly one release/* branch on origin, found $count" >&2
        [ "$count" -eq 0 ] && echo "Open a cycle first: ci-autorelease open" >&2
        printf '%s\n' "$branches" | sed 's/^/  /' >&2
        exit 1
      fi
      # `strip=3` already removed `refs/remotes/origin`, so what is left IS the
      # branch name. Re-prefixing it would ask for `origin/release/release/…`.
      branch="$branches"
      git -C "$CHECKOUT" checkout --quiet -B "$branch" "origin/$branch"
      git -C "$CHECKOUT" reset --quiet --hard "origin/$branch"
    fi
    git -C "$CHECKOUT" clean -qfd

    # `cut-rc.sh` writes an annotated tag, and git refuses to author one
    # without an identity. The agentforge account has no global git config —
    # deliberately, since every clone on the box belongs to a different
    # purpose — so the first cut died on "Please tell me who you are" after
    # having already created the Linear release issue. Set it on the checkout
    # this wrapper owns, and nowhere else. This is the *tagger*; who pushes is
    # a separate question, answered by the account's credentials below.
    git -C "$CHECKOUT" config user.name "The Architect"
    git -C "$CHECKOUT" config user.email "architect@sideprojectslab.com"

    # cut-rc.sh pushes the tag itself, and the push identity matters more than
    # it looks: a tag pushed with a workflow's GITHUB_TOKEN fires no workflow,
    # so the release gate would never start and the chain would die at an
    # un-QA'd candidate. Here the push uses the agentforge account's own git
    # credentials, which are a real account and do trigger workflows. Fail
    # loudly rather than push as nobody.
    if ! git -C "$CHECKOUT" ls-remote --quiet origin HEAD >/dev/null 2>&1; then
      echo "ci-autorelease: the agentforge account has no push credentials for origin" >&2
      exit 1
    fi

    open_flag=()
    [ "$verb" = open ] && open_flag=(--open)
    if [ -n "$arg" ]; then
      exec "$CHECKOUT/scripts/cut-rc.sh" "${open_flag[@]}" --class "$arg"
    fi
    exec "$CHECKOUT/scripts/cut-rc.sh" "${open_flag[@]}"
    ;;

  deploy)
    # SID-616: this verb has known the exact version to install since the
    # argv validation above (a released, published tag) — but used to throw
    # that away and tell `update apply` to run bare, which asks pip for
    # "whatever is latest". pip caches the index it reads from for ~10
    # minutes, so a deploy landing inside that window installed the PREVIOUS
    # release while `verify` (below) expected the one just published — seen
    # live on 2026-09-16 (0.4.101 installed for a 0.4.102 deploy, run 2).
    # Passing the version through makes the install exact instead of
    # eventually-consistent. Re-running against a version already installed
    # is safe but NOT a no-op: `pip` itself reports "already satisfied", but
    # `update apply` still runs migrations, syncs assets, healthchecks and
    # restarts units, and rewrites data/version.json — every call, pinned or
    # not. What it does NOT do on a repeat call is touch `previous_version`,
    # so a redundant `deploy X` can't make `rollback` a silent no-op
    # (agentforge/updater.py, apply_update).
    export AGENTFORGE_ROOT="$ROOT"
    export AGENTFORGE_RUNTIME_ROOT="$ROOT/runtime"
    "$AF" update apply --yes --version "$arg"
    # Re-enter through the CLI dispatch (`agentforge ci autorelease verify`),
    # not `exec "$0"`: `$0` is this packaged script's own data-file path, and
    # `launcher.py` deliberately does not trust that path's +x bit surviving a
    # wheel install — re-execing it directly would depend on exactly the bit
    # the launcher exists to route around. This also means what runs next is
    # whatever `update apply` just installed, not the version that started —
    # by design, since verifying the OLD build after a deploy would prove
    # nothing.
    exec "$AF" ci autorelease verify "$arg"
    ;;

  verify)
    # JSON on stdout, exit 1 when the box does not actually run what was asked.
    # "Installed" is not "running": the 2026-09-04 deploy wrote the new version
    # to disk while every unit kept serving the old one, and nothing noticed.
    exec "$PY" - "$ROOT" "${arg:-}" <<'PY'
import json, os, subprocess, sys

root, wanted = sys.argv[1], sys.argv[2]
out = {"installed_version": None, "wanted": wanted or None, "units": [], "problems": []}

try:
    with open(os.path.join(root, "data", "version.json"), encoding="utf-8") as fh:
        out["installed_version"] = json.load(fh).get("installed_version")
except Exception as exc:
    out["problems"].append(f"version.json unreadable: {exc}")

if wanted and out["installed_version"] != wanted:
    out["problems"].append(f"installed {out['installed_version']!r}, wanted {wanted!r}")

# systemctl --user needs the user bus, and under sudo the environment that
# points at it is gone. Restoring it is what turns "no service found" back into
# the truth; an unreachable bus is reported, never read as "nothing to check".
env = dict(os.environ)
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
env.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path={env['XDG_RUNTIME_DIR']}/bus")
try:
    proc = subprocess.run(
        ["systemctl", "--user", "list-units", "--type=service", "--state=running",
         "--no-legend", "--plain", "agentforge-*.service"],
        capture_output=True, text=True, env=env, timeout=30,
    )
    if proc.returncode != 0:
        out["problems"].append(f"systemctl --user failed: {proc.stderr.strip()[:200]}")
    else:
        out["units"] = [line.split()[0] for line in proc.stdout.splitlines() if line.strip()]
        if not out["units"]:
            out["problems"].append("no agentforge-*.service is running")
except FileNotFoundError:
    out["problems"].append("systemctl not found")
except subprocess.TimeoutExpired:
    out["problems"].append("systemctl --user timed out")

out["ok"] = not out["problems"]
json.dump(out, sys.stdout, indent=2)
sys.stdout.write("\n")
sys.exit(0 if out["ok"] else 1)
PY
    ;;

  rollback)
    # `--yes` because there is no terminal here. Without it the verb reads EOF,
    # prints "Aborted" and exits 0 — a rollback that did nothing, indis-
    # tinguishable from one that worked (fixed alongside this file in SID-388).
    # Same explicit-instance discipline as `deploy` (SID-599): without these,
    # `update rollback` under sudo -n -u agentforge (env_reset) cannot find
    # this instance's version.json and exits 1 — the one path meant to recover
    # a failed deploy.
    export AGENTFORGE_ROOT="$ROOT"
    export AGENTFORGE_RUNTIME_ROOT="$ROOT/runtime"
    exec "$AF" update rollback --yes
    ;;

  notify)
    # Message on stdin, never in argv: a release note carries newlines, quotes
    # and URLs, and none of that belongs on a command line.
    msg="$(cat)"
    [ -n "$msg" ] || { echo "ci-autorelease notify: empty message on stdin" >&2; exit 2; }
    export AGENTFORGE_ROOT="$ROOT"
    export AGENTFORGE_RUNTIME_ROOT="$ROOT/runtime"
    # `telegram_send.sh` resolves the bot token from AGENT_NAME under
    # `set -u`. Release failures are authored by the Architect identity, but
    # their destination remains the validated topic supplied above.
    export AGENT_NAME=architect
    exec "$ROOT/runtime/scripts/telegram_send.sh" "$arg" "$msg"
    ;;
esac
