#!/usr/bin/env bash
# scripts/reap-test-dbs — SessionStart sweep that removes per-worktree
# `precis-test-*` compose projects whose worktree no longer exists.
#
# The leak this closes: teardown already exists and is correct —
# scripts/hooks/session-end-reap.sh (the primary) and scripts/reap-worktrees
# (its SessionStart backstop) both `down -v` the project as they remove a
# tree. But both are COUPLED TO A REMOVAL PATH they control. Every other way
# a worktree dies — the ExitWorktree tool, the housekeeper agent, a hand-run
# `git worktree remove`, an `rm -rf` + prune, or either reaper's own
# best-effort `docker compose down` failing transiently
# (it is `|| true`) — takes the tree and strands the Postgres. And because the
# teardown only ever fires at the instant of removal, a miss is PERMANENT:
# nothing reconciles afterwards. Stranded test DBs are not free — each is a
# live postgres on the 8GB colima VM, and that contention is the documented
# cause of the silent gate OOM-137 reds (see auto-memory `gate-oom-silent-death`,
# gr176231).
#
# Chasing each removal path with its own hook is open-ended. This is
# state-based instead: it asks "does this project's worktree still exist?" and
# converges, whatever removed the tree.
#
# How orphan-ness is decided: compose already stamps every container with
#     com.docker.compose.project.config_files
#       = <worktree>/docker/dev/compose.yaml
# so the worktree path is a FACT READ OFF THE CONTAINER, not inferred from the
# project name. That matters — `compose_project_for` (scripts/lib/
# compose-project.sh) sanitises the basename and is deliberately not
# injective, so a project name cannot be mapped back to a path at all.
# Reading the label also makes this robust across clones: a second checkout's
# projects carry their own root and are judged on their own terms.
#
# Safety: a tree that still EXISTS is never touched, so this cannot race a
# parked session or an in-flight gate — the criterion is absence of the
# bind-mount source, and a gate cannot run without it. Idle-but-live DBs are
# deliberately out of scope; stopping those needs a liveness judgment
# (scripts/inflight), not a file test.
#
# Network-only sweep (gr311857): the container-label reconciliation above
# needs a container to read the worktree path off. A project that never has
# one left (a bare `docker network create`, or the last container gone via
# some path other than `down -v`) leaves an orphaned `precis-test-*_default`
# network sitting on the Docker daemon's address-pool ceiling forever — the
# structural cause of gr311857 (fresh worktrees locked out of minting their
# first network once the fleet's pool fills up). Since a network carries no
# path label to reconcile against, that half instead asks the inverse
# question — "does any CURRENT git worktree still mint this project name?" —
# via the same `compose_project_for` every caller uses, so it can never
# disagree with what scripts/test/scripts/dev/scripts/ship would create next.
#
# Note on volumes: docker/dev/compose.yaml declares no named volumes (pgdata
# lives on the container overlay), so `down -v` is belt-and-braces — there is
# no dangling-volume population for this to miss.
#
# Abandoned-but-present carve-out (gr331378): both sweeps above are still
# COUPLED TO A REMOVAL PATH — the worktree DIRECTORY has to be gone. But
# `scripts/inflight`'s VS-MAIN bucketing has its own gap (a worktree with
# several local commits whose cumulative diff was squash-merged as ONE
# commit on main gets stuck `has_unmerged_work` forever — per-commit
# patch-id matching never sees it), so a genuinely-abandoned subagent
# worktree can sit there, directory intact, never reaped, its
# `precis-test-<name>` project's db container running for days. This third
# pass reaps a project whose worktree is STILL PRESENT, but only when ALL
# of the following hold, each independently conservative:
#   1. no live session lock on the worktree — read off the exact `session`
#      field `scripts/inflight --json` already computes (never
#      reimplemented), so this can never disagree with what a human running
#      `scripts/inflight` sees.
#   2. `.claude/purpose` is absent or older than
#      PRECIS_REAP_DB_PURPOSE_FRESH_SECONDS (default 6h) — same tripwire
#      `scripts/reap-worktrees` uses for the analogous worktree-removal case.
#   3. the project has no RUNNING container other than the test-db service
#      (a `precis-gate` or `run --rm` container means a gate is actually
#      in flight right now — never touch that).
#   4. the test-db container itself has been running for at least
#      PRECIS_REAP_DB_MIN_AGE_SECONDS (default 48h) — a fresh test-db is
#      almost certainly mid-use; a multi-day-old one with nothing else
#      running and no recent purpose is not.
# All four together are meant to be far more conservative than "would
# scripts/inflight call this safe_remove" — this sweep never removes the
# worktree itself, only its docker footprint, so a false negative (leaving a
# leaked project alone) is free while a false positive (downing an
# in-progress session's db) is not. Skips outright if `scripts/inflight` is
# missing/non-executable: no session-liveness signal, no reap.
#
# Escape hatch: PRECIS_NO_AUTOREAP=1 → no-op (shared with reap-worktrees).
# Usage: scripts/reap-test-dbs [--dry-run]
#
# Wired in .claude/settings.json (SessionStart), after `reap-worktrees` — so
# the eager teardown gets first refusal and this only sees what it missed.
set -uo pipefail

[ -n "${PRECIS_NO_AUTOREAP:-}" ] && exit 0

DRY_RUN=0
[ "${1:-}" = "--dry-run" ] && DRY_RUN=1

command -v docker >/dev/null 2>&1 || exit 0
# A stopped colima / unreachable daemon must never make a SessionStart hook
# noisy or slow — `docker ps` blocks on the socket, so bail on any failure.
docker ps -a --format '{{.Names}}' >/dev/null 2>&1 || exit 0

REAPED=()
SEEN=""

while IFS='|' read -r proj cfg; do
    [ -z "$proj" ] && continue
    case "$proj" in
        precis-test-*) ;;
        *) continue ;;
    esac
    # One project has many containers (db + each `run --rm` that outlived its
    # exit); judge each project once.
    case " $SEEN " in *" $proj "*) continue ;; esac
    SEEN="$SEEN $proj"

    # config_files is comma-separated when compose was given several -f flags;
    # the dev loop passes exactly one, and the first is the anchor either way.
    cfg="${cfg%%,*}"
    [ -z "$cfg" ] && continue

    # Derive the worktree root by stripping the known suffix. If the label does
    # not end in it, the project came from some other layout — leave it alone
    # rather than guess at a path we would then delete on.
    root="${cfg%/docker/dev/compose.yaml}"
    [ "$root" = "$cfg" ] && continue
    # Refuse to act on a degenerate root: `[ -d / ]` is true so it would be
    # kept, but an empty one would test false and reap. Guard explicitly.
    [ -z "$root" ] && continue

    [ -d "$root" ] && continue   # tree still there — not our business

    if [ "$DRY_RUN" = 1 ]; then
        REAPED+=("$proj")
        continue
    fi
    # `down -p <name>` finds the project by label, so no compose file is
    # needed — which is the point, the file went with the tree. Best-effort:
    # a concurrent session reaping the same project, or a half-removed one,
    # must never fail the hook.
    if env UID="$(id -u)" GID="$(id -g)" \
        docker compose -p "$proj" down -v >/dev/null 2>&1; then
        REAPED+=("$proj")
    fi
done < <(docker ps -a --format \
    '{{.Label "com.docker.compose.project"}}|{{.Label "com.docker.compose.project.config_files"}}' \
    2>/dev/null)

# Network-only sweep (gr311857): `docker compose up -d` mints the project's
# ONE network for its whole lifetime, but a `run --rm` container (scripts/
# test's/scripts/dev's test/tool invocations) never sticks around, and even
# the persistent `precis-test-db` container can go missing without the
# network following it (a half-finished `compose down`, a manual `docker rm`,
# `docker container prune`, …). Those networks are invisible to the
# container-label sweep above (SEEN only ever holds projects that still have
# AT LEAST ONE container) — and they still count against the Docker daemon's
# address-pool ceiling (gr311857: observed pegged at the ~32-network cap,
# starving new worktrees of their first network). So: also look at bare
# networks and, for any `precis-test-*` project with NO container at all
# (not in SEEN), tear it down UNLESS a currently-existing git worktree would
# still mint that exact project name — computed the same way scripts/test/
# scripts/dev/scripts/ship do, via compose_project_for, never by trying to
# invert the (deliberately non-injective) project name back into a path.
# Best-effort throughout: a missing git/compose-project.sh, or a `docker
# network rm` racing a concurrent reaper, must never fail this hook.
ROOT="$(cd "$(dirname "$0")/.." 2>/dev/null && pwd -P || true)"
if [ -n "$ROOT" ] && [ -f "$ROOT/scripts/lib/compose-project.sh" ] \
    && git -C "$ROOT" rev-parse --git-dir >/dev/null 2>&1; then
    source "$ROOT/scripts/lib/compose-project.sh"
    LIVE_PROJECTS=""
    while IFS= read -r wt_path; do
        [ -z "$wt_path" ] && continue
        p="$(compose_project_for "$wt_path" 2>/dev/null || true)"
        [ -n "$p" ] && LIVE_PROJECTS="$LIVE_PROJECTS $p"
    done < <(git -C "$ROOT" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}')

    while IFS='|' read -r net proj; do
        [ -z "$net" ] && continue
        case "$proj" in
            precis-test-*) ;;
            *) continue ;;
        esac
        case " $SEEN " in *" $proj "*) continue ;; esac   # has a container — handled above
        case " $LIVE_PROJECTS " in *" $proj "*) continue ;; esac   # a live worktree still owns this name

        if [ "$DRY_RUN" = 1 ]; then
            REAPED+=("$net")
            continue
        fi
        if docker network rm "$net" >/dev/null 2>&1; then
            REAPED+=("$net")
        fi
    done < <(docker network ls --format '{{.Name}}|{{.Label "com.docker.compose.project"}}' 2>/dev/null)

    # Abandoned-but-present sweep (gr331378) — see the header comment for the
    # four-way guard this implements. Only reachable when the block above's
    # own preconditions held (ROOT resolved, compose-project.sh sourced, a
    # readable git repo) — folded into the same `if`, and additionally gated
    # on `scripts/inflight` existing, since criterion 1 reads its `session`
    # field verbatim and there is no safe way to guess sessionlessness
    # without it.
    if [ -x "$ROOT/scripts/inflight" ]; then
        DB_MIN_AGE_SECONDS="${PRECIS_REAP_DB_MIN_AGE_SECONDS:-172800}"        # 48h
        DB_PURPOSE_FRESH_SECONDS="${PRECIS_REAP_DB_PURPOSE_FRESH_SECONDS:-21600}"  # 6h
        PRIMARY_WT="$(git -C "$ROOT" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')"
        INFLIGHT_JSON="$("$ROOT/scripts/inflight" --json 2>/dev/null || true)"

        # session_for <path> — the exact `session` field scripts/inflight
        # --json computed for <path> (live#<pid> / dead-lock#<pid> / locked /
        # —), empty if the worktree isn't in the blob at all.
        session_for() {
            [ -z "$INFLIGHT_JSON" ] && return 0
            printf '%s' "$INFLIGHT_JSON" | TARGET="$1" python3 -c '
import json, os, sys
try:
    data = json.load(sys.stdin)
except ValueError:
    sys.exit(0)
target = os.path.realpath(os.environ["TARGET"])
for wt in data.get("worktrees", []):
    if os.path.realpath(wt.get("path", "")) == target:
        print(wt.get("session", ""))
        break
'
        }

        # stale_or_absent_purpose <path> — true (0) when <path>/.claude/purpose
        # is missing or its mtime is at least DB_PURPOSE_FRESH_SECONDS old.
        stale_or_absent_purpose() {
            python3 - "$1/.claude/purpose" "$DB_PURPOSE_FRESH_SECONDS" <<'PY'
import os
import sys
import time

pf, threshold = sys.argv[1], float(sys.argv[2])
try:
    age = time.time() - os.path.getmtime(pf)
except OSError:
    sys.exit(0)  # no purpose file at all -> counts as stale
sys.exit(0 if age >= threshold else 1)
PY
        }

        # container_old_enough <container-id> — true (0) when the
        # container's `Created` timestamp is at least DB_MIN_AGE_SECONDS old.
        container_old_enough() {
            local cid=$1 created
            created="$(docker inspect -f '{{.Created}}' "$cid" 2>/dev/null)" || return 1
            [ -z "$created" ] && return 1
            python3 - "$created" "$DB_MIN_AGE_SECONDS" <<'PY'
import datetime
import sys

ts, threshold = sys.argv[1], float(sys.argv[2])
ts = ts.strip()
if ts.endswith("Z"):
    ts = ts[:-1]
ts = ts.split(".")[0]  # drop fractional seconds; keeps this parseable pre-3.11 too
try:
    created = datetime.datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S").replace(
        tzinfo=datetime.timezone.utc
    )
except ValueError:
    sys.exit(1)
age = (datetime.datetime.now(datetime.timezone.utc) - created).total_seconds()
sys.exit(0 if age >= threshold else 1)
PY
        }

        while IFS= read -r wt_path; do
            [ -z "$wt_path" ] && continue
            [ "$wt_path" = "$PRIMARY_WT" ] && continue
            [ -d "$wt_path" ] || continue   # gone already -- the label sweep above owns that case

            proj="$(compose_project_for "$wt_path" 2>/dev/null || true)"
            case "$proj" in precis-test-*) ;; *) continue ;; esac
            # Only a project that already has a container (SEEN, from the
            # label sweep above) has a db container to judge an age against.
            case " $SEEN " in *" $proj "*) ;; *) continue ;; esac

            # (1) no live session lock on the worktree.
            sess="$(session_for "$wt_path")"
            case "$sess" in live#*) continue ;; esac

            # (2) .claude/purpose absent or stale.
            stale_or_absent_purpose "$wt_path" || continue

            # (3) no running container in this project other than the
            # test-db service — a gate or `run --rm` container in flight
            # means real work is happening right now.
            other_running=0
            db_cid=""
            while IFS='|' read -r cid svc; do
                [ -z "$cid" ] && continue
                if [ "$svc" = "precis-test-db" ]; then
                    db_cid="$cid"
                else
                    other_running=1
                fi
            done < <(docker ps --filter "label=com.docker.compose.project=$proj" \
                --format '{{.ID}}|{{.Label "com.docker.compose.service"}}' 2>/dev/null)
            [ "$other_running" = 1 ] && continue
            [ -z "$db_cid" ] && continue   # no running db either -- nothing to reap here

            # (4) the db container has been up at least DB_MIN_AGE_SECONDS.
            container_old_enough "$db_cid" || continue

            if [ "$DRY_RUN" = 1 ]; then
                REAPED+=("$proj")
                continue
            fi
            if env UID="$(id -u)" GID="$(id -g)" \
                docker compose -p "$proj" down -v >/dev/null 2>&1; then
                REAPED+=("$proj")
            fi
        done < <(git -C "$ROOT" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}')
    fi
fi

if [ "${#REAPED[@]}" -eq 0 ]; then
    echo "reap-test-dbs: nothing to reap"
elif [ "$DRY_RUN" = 1 ]; then
    echo "reap-test-dbs --dry-run: would reap ${#REAPED[@]}: ${REAPED[*]}"
else
    echo "reap-test-dbs: reaped ${#REAPED[@]}: ${REAPED[*]}"
fi
exit 0
