#!/usr/bin/env bash
# Restart an agent whose Claude session is parked at an expired login.
#
#   ./sylph-watchdog            watch forever (run detached)
#   ./sylph-watchdog --once     one pass, for cron or a manual check
#   ./sylph-watchdog --status   what it would do right now, changing nothing
#
# Env: SYLPH_WATCH_INTERVAL (default 300s), SYLPH_WATCH_CONTAINERS
#
# ── Why this exists ─────────────────────────────────────────────────────────
#
# 🔴 `--restart unless-stopped` DOES NOT COVER THIS, and that is the whole point.
# Docker restarts a container that EXITS. A Claude session sitting at
#
#     Login expired · Please run /login
#
# never exits. The process is healthy, the container is Up, `docker ps` is green,
# and the agent has done nothing for hours. Three times now (2026-08-30,
# 09-02, 09-03) that has been noticed only because a human saw Remote Control
# report "Can't reach your computer" — which is a symptom of the session being
# unable to attach, not a report about the machine.
#
# The fix is already in the entrypoint: it copies the host's credentials in when
# they are newer than the container's. It just needs something to notice and
# bounce the container. That is all this does.
#
# ⚠️ It restarts rather than logging in. A restart re-runs the entrypoint, which
# re-seeds credentials AND resumes the session by id, so the agent keeps its
# context. There is nothing here that could log a session in on its own, and it
# should not pretend to: if the HOST's credentials are also stale, this loop will
# bounce the container and the agent will park again. It says so instead of
# retrying silently.
set -uo pipefail

CONTAINERS="${SYLPH_WATCH_CONTAINERS:-sylpheed-agent sylpheed-port}"
INTERVAL="${SYLPH_WATCH_INTERVAL:-300}"

# How far back to look. Longer than the interval so a stall spanning two passes
# is still seen, short enough that a login expiry cured an hour ago does not
# read as current.
WINDOW="${SYLPH_WATCH_WINDOW:-20m}"

log() { printf '[watchdog %s] %s\n' "$(date -u '+%H:%M:%S')" "$*"; }

# Has this container printed an expiry recently, and NOT recovered since?
#
# "Recovered" matters: the string stays in the log forever, so a bare grep would
# restart a healthy agent every pass on the strength of an hours-old line. The
# test is whether the transcript has been written SINCE the last expiry — a
# working agent writes constantly.
parked() {
  local c="$1"
  docker ps --filter "name=^${c}$" --format '{{.Names}}' | grep -q . || return 1

  local hits
  hits=$(docker logs --since "$WINDOW" "$c" 2>&1 \
         | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g' \
         | grep -c 'Login expired' 2>/dev/null || true)
  [ "${hits:-0}" -gt 0 ] || return 1

  # Transcript idle for longer than one interval => it really is stuck. A busy
  # agent that merely logged an expiry and recovered keeps writing.
  local age
  age=$(docker exec "$c" bash -lc '
      f=$(ls -1t "$HOME/.claude/projects"/*/*.jsonl 2>/dev/null | head -1)
      [ -n "$f" ] && echo $(( $(date +%s) - $(stat -c %Y "$f") )) || echo 999999
    ' 2>/dev/null | tr -d '[:space:]')
  case "$age" in ''|*[!0-9]*) age=999999 ;; esac
  [ "$age" -gt "$INTERVAL" ]
}

# Is the HOST's copy actually newer? If not, a restart cannot help and saying so
# is the useful output — otherwise this becomes a loop that bounces a container
# every five minutes and reports success.
host_is_newer() {
  local c="$1"
  docker exec "$c" bash -lc '
    s="$HOME/.claude.seed/.credentials.json"; o="$HOME/.claude/.credentials.json"
    [ -e "$s" ] || exit 2
    [ ! -e "$o" ] || [ "$s" -nt "$o" ]
  ' >/dev/null 2>&1
}

pass() {
  local acted=0
  for c in $CONTAINERS; do
    if parked "$c"; then
      if host_is_newer "$c"; then
        log "$c is parked at an expired login; host credentials are newer -- restarting"
        [ "${1:-}" = "--status" ] || docker restart "$c" >/dev/null 2>&1 \
          && log "$c restarted (entrypoint re-seeds and resumes the session)"
      else
        log "🔴 $c is parked at an expired login and the HOST's credentials are"
        log "   NO NEWER. A restart cannot fix this -- log in on the host first."
      fi
      acted=1
    fi
  done
  [ "$acted" = 0 ] && log "all watched agents are alive"
  return 0
}

# ── The control, EXECUTED ───────────────────────────────────────────────────
#
# 🔴 A watchdog that has never fired is a hope, not a guard. Its whole value is
# in the true-positive path, and that path only runs when an agent is already
# broken -- so it gets a synthetic one.
#
# Two cases against real containers, because the detection is `docker logs` plus
# `docker exec` and neither can be reasoned about from the shell:
#
#   a container printing "Login expired" with no transcript  -> parked   (TRUE positive)
#   a live agent                                             -> not parked (negative)
#
# ⚠️ Written after claiming, wrongly and without checking, that a bare grep
# "would have fired" on a recovered container. The count was zero. That is the
# same error this whole corpus keeps cataloguing -- asserting what an instrument
# would have said instead of running it -- so the instrument now runs.
selftest() {
  local ok=0 name="sylph-watchdog-control-$$"
  echo "control:"

  docker run -d --rm --name "$name" alpine:latest \
    sh -c 'echo "Login expired · Please run /login"; sleep 120' >/dev/null 2>&1
  # Give docker a moment to have the line available in the log.
  for _ in 1 2 3 4 5; do
    docker logs "$name" 2>&1 | grep -q 'Login expired' && break
    sleep 1
  done

  if SYLPH_WATCH_CONTAINERS="$name" parked "$name"; then
    printf '  %-46s ✅\n' "an expired login with no transcript reads PARKED"
  else
    printf '  %-46s 🔴\n' "an expired login with no transcript reads PARKED"; ok=1
  fi

  # And it must NOT fire on the same container once it is gone -- a stopped
  # container is not a parked one, and restarting it would be wrong.
  docker rm -f "$name" >/dev/null 2>&1
  if parked "$name"; then
    printf '  %-46s 🔴\n' "a container that is gone reads NOT parked"; ok=1
  else
    printf '  %-46s ✅\n' "a container that is gone reads NOT parked"
  fi

  # The live negative, against whatever is actually running.
  local live=0
  for c in $CONTAINERS; do
    docker ps --filter "name=^${c}$" --format '{{.Names}}' | grep -q . || continue
    live=1
    if parked "$c"; then
      printf '  %-46s 🔴  (%s)\n' "a working agent reads NOT parked" "$c"; ok=1
    else
      printf '  %-46s ✅  (%s)\n' "a working agent reads NOT parked" "$c"
    fi
  done
  [ "$live" = 1 ] || printf '  %-46s --  no agent running\n' "a working agent reads NOT parked"

  echo
  [ $ok -eq 0 ] && echo "the watchdog fires on a parked session and not otherwise" \
                || echo "🔴 the watchdog cannot tell parked from alive"
  return $ok
}

case "${1:-}" in
  --once)     pass ;;
  --status)   pass --status ;;
  --selftest) selftest; exit $? ;;
  *)
    log "watching [$CONTAINERS] every ${INTERVAL}s"
    while true; do
      pass
      sleep "$INTERVAL"
    done
    ;;
esac
