#!/usr/bin/env bash
# Agent session ledger: durability, liveness classification, uncommitted-work detection,
# notification dedupe, and survival of terminal death.
#
# Every process this test signals is one it started itself. It never touches a real agent
# session, never runs a resource-exhaustion workload, and never writes to a repo it did
# not create.
set -uo pipefail

REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)
MODULE="$REPO_ROOT/modules/workstation/claude"
PASS=0
FAIL=0

ok() {
  PASS=$((PASS + 1))
  printf 'ok   %s\n' "$1"
}
no() {
  FAIL=$((FAIL + 1))
  printf 'FAIL %s\n' "$1"
}
check() { if [[ $2 == "$3" ]]; then ok "$1"; else no "$1 (want '$3', got '$2')"; fi; }

TESTROOT=$(mktemp -d -t agent-ledger-test.XXXXXX)
STARTED_PIDS=()
cleanup() {
  local pid
  for pid in "${STARTED_PIDS[@]:-}"; do
    [[ -n $pid ]] && kill -9 "$pid" 2>/dev/null
  done
  rm -rf "$TESTROOT"
}
trap cleanup EXIT

export AGENT_SESSIONS_DIR="$TESTROOT/ledger"
FAKE_HOME="$TESTROOT/home"
mkdir -p "$FAKE_HOME/.claude"
ln -s "$MODULE/lib" "$FAKE_HOME/.claude/lib"

# A launch chain that mirrors the real one minus the cgroup confinement, which is covered
# by its own test below against the real _agent-build-scope.
SHIMBIN="$TESTROOT/shimbin"
REALBIN="$TESTROOT/realbin"
mkdir -p "$SHIMBIN" "$REALBIN" "$TESTROOT/lib"
cp "$MODULE/bin/_tmpjail-shim.sh" "$SHIMBIN/_tmpjail-shim.sh"
cp "$MODULE/lib/shim-guard.sh" "$TESTROOT/lib/shim-guard.sh"
ln -s "$SHIMBIN/_tmpjail-shim.sh" "$SHIMBIN/stubagent"
printf '#!/bin/sh\nshift\nexec "$@"\n' >"$SHIMBIN/_agent-build-scope"
printf '#!/bin/sh\nexec sleep "${1:-30}"\n' >"$REALBIN/stubagent"
chmod +x "$SHIMBIN/_agent-build-scope" "$REALBIN/stubagent"

reader() { AGENT_SESSION_LEDGER_HOOK_TEST=1 node --input-type=module -e "$1"; }

# --- 1. entry is on disk before the process can die, and survives SIGKILL ---------------
HOME="$FAKE_HOME" PATH="$SHIMBIN:$REALBIN:$PATH" env -u TMPJAIL_ACTIVE -u DTACH setsid "$SHIMBIN/stubagent" 300 </dev/null >/dev/null 2>&1 &
launch_pid=$!
STARTED_PIDS+=("$launch_pid")
for _ in $(seq 1 50); do
  [[ -n $(ls "$AGENT_SESSIONS_DIR/sessions" 2>/dev/null) ]] && break
  sleep 0.1
done
entry_file=$(ls "$AGENT_SESSIONS_DIR/sessions"/*.json 2>/dev/null | head -1)
[[ -n $entry_file ]] && ok "shim writes a ledger entry at launch" || no "shim writes a ledger entry at launch"
ledger_id=$(basename "${entry_file:-none.json}" .json)

started=$(sed -n 's/.*"startedAt": "\([^"]*\)".*/\1/p' "$entry_file")
skew=$(( $(date +%s) - $(date -d "$started" +%s 2>/dev/null || echo 0) ))
if (( skew > -120 && skew < 120 )); then
  ok "startedAt is UTC, not the local clock labelled Z"
else
  no "startedAt is UTC, not the local clock labelled Z (got '$started', ${skew}s off)"
fi

# The agent process is a grandchild; find it by the exported id, exactly as the reader does.
agent_pid=""
for _ in $(seq 1 50); do
  for p in $(pgrep -u "$USER" sleep 2>/dev/null); do
    if tr '\0' '\n' <"/proc/$p/environ" 2>/dev/null | grep -qx "AGENT_LEDGER_ID=$ledger_id"; then
      agent_pid=$p
      break
    fi
  done
  [[ -n $agent_pid ]] && break
  sleep 0.1
done
[[ -n $agent_pid ]] && ok "AGENT_LEDGER_ID reaches the launched process" ||
  no "AGENT_LEDGER_ID reaches the launched process"
STARTED_PIDS+=("$agent_pid")

# --- 2. a live session is not misclassified as orphaned --------------------------------
state=$(reader "
import { classify, readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const s = await classify(readEntries(), { sampleMs: 100, includeDirty: false });
process.stdout.write(s.find((x) => x.ledgerId === '$ledger_id')?.state ?? 'MISSING');
")
if [[ $state == ALIVE-* ]]; then ok "live session classified $state"; else no "live session classified $state"; fi

kill -9 "$agent_pid" 2>/dev/null
kill -9 "$launch_pid" 2>/dev/null
sleep 0.3

[[ -s $entry_file ]] && ok "entry survives SIGKILL of the session process" ||
  no "entry survives SIGKILL of the session process"

# --- 3. a killed session with no clean finish is ORPHANED ------------------------------
state=$(reader "
import { classify, readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const s = await classify(readEntries(), { sampleMs: 50, includeDirty: false });
process.stdout.write(s.find((x) => x.ledgerId === '$ledger_id')?.state ?? 'MISSING');
")
check "killed session classified ORPHANED" "$state" "ORPHANED"

# --- 4. a cleanly finished session is FINISHED, not ORPHANED ---------------------------
state=$(reader "
import { classify, readEntries, updateEntry } from '$MODULE/lib/agent-session-reader.mjs';
updateEntry('$ledger_id', { finishedAt: new Date().toISOString(), finishReason: 'test' });
const s = await classify(readEntries(), { sampleMs: 50, includeDirty: false });
process.stdout.write(s.find((x) => x.ledgerId === '$ledger_id')?.state ?? 'MISSING');
")
check "cleanly finished session classified FINISHED" "$state" "FINISHED"

# --- 5. uncommitted-work detection ------------------------------------------------------
WORKREPO="$TESTROOT/workrepo"
mkdir -p "$WORKREPO"
git -C "$WORKREPO" init -q -b main
git -C "$WORKREPO" -c user.email=t@t -c user.name=t commit -q --allow-empty -m base
dirty=$(reader "
import { classify } from '$MODULE/lib/agent-session-reader.mjs';
const base = { schemaVersion: 1, ledgerId: 'x', runtime: 'claude', startedAt: new Date().toISOString(), cwd: '$WORKREPO', mux: { kind: null, socket: null, target: null } };
const clean = await classify([base], { sampleMs: 10 });
process.stdout.write(String(clean[0].dirtyCount));
")
check "clean worktree reports 0 uncommitted files" "$dirty" "0"

printf 'scratch\n' >"$WORKREPO/untracked.txt"
printf 'more\n' >"$WORKREPO/second.txt"
dirty=$(reader "
import { classify } from '$MODULE/lib/agent-session-reader.mjs';
const base = { schemaVersion: 1, ledgerId: 'x', runtime: 'claude', startedAt: new Date().toISOString(), cwd: '$WORKREPO', mux: { kind: null, socket: null, target: null } };
const s = await classify([base], { sampleMs: 10 });
process.stdout.write(String(s[0].dirtyCount));
")
check "dirty worktree reports 2 uncommitted files" "$dirty" "2"

# --- 6. notification dedupe -------------------------------------------------------------
dedupe=$(reader "
import { selectUnnotified } from '$MODULE/hooks/agent-session-ledger.mjs';
const s = [{ ledgerId: 'a', state: 'ORPHANED', dirtyCount: 3, runtime: 'claude', cwd: '/tmp', idleMs: 60000 }];
const store = {};
const first = selectUnnotified(s, store).length;
const second = selectUnnotified(s, store).length;
process.stdout.write(first + ',' + second);
")
check "orphan notified once, never repeated" "$dedupe" "1,0"

quiet=$(reader "
import { selectUnnotified } from '$MODULE/hooks/agent-session-ledger.mjs';
const s = [{ ledgerId: 'b', state: 'ORPHANED', dirtyCount: 0 }, { ledgerId: 'c', state: 'ALIVE-IDLE', dirtyCount: 9 }];
process.stdout.write(String(selectUnnotified(s, {}).length));
")
check "no notification without abandoned uncommitted work" "$quiet" "0"

# --- 7. terminal death detaches instead of killing the session -------------------------
if command -v dtach >/dev/null 2>&1 && command -v script >/dev/null 2>&1; then
  rm -f "$AGENT_SESSIONS_DIR"/sessions/*.json
  HOME="$FAKE_HOME" PATH="$SHIMBIN:$REALBIN:$PATH" env -u TMPJAIL_ACTIVE -u DTACH setsid script -qfec "$SHIMBIN/stubagent 300" /dev/null \
    >/dev/null 2>&1 &
  term_pid=$!
  STARTED_PIDS+=("$term_pid")
  wrapped_id=""
  for _ in $(seq 1 60); do
    f=$(ls "$AGENT_SESSIONS_DIR/sessions"/*.json 2>/dev/null | head -1)
    if [[ -n $f ]] && grep -q '"kind": "dtach"' "$f"; then
      wrapped_id=$(basename "$f" .json)
      break
    fi
    sleep 0.1
  done
  if [[ -n $wrapped_id ]]; then
    ok "interactive launch is wrapped in a detachable container"
    socket="$AGENT_SESSIONS_DIR/sock/$wrapped_id"
    wrapped_agent=""
    for _ in $(seq 1 60); do
      for p in $(pgrep -u "$USER" sleep 2>/dev/null); do
        if tr '\0' '\n' <"/proc/$p/environ" 2>/dev/null | grep -qx "AGENT_LEDGER_ID=$wrapped_id"; then
          wrapped_agent=$p
          break
        fi
      done
      [[ -n $wrapped_agent ]] && break
      sleep 0.1
    done
    STARTED_PIDS+=("$wrapped_agent")

    # Kill the terminal, not the session -- this is the crash the user keeps hitting.
    kill -9 "$term_pid" 2>/dev/null
    sleep 0.5
    if [[ -n $wrapped_agent ]] && kill -0 "$wrapped_agent" 2>/dev/null; then
      ok "session survives the death of its terminal"
    else
      no "session survives the death of its terminal"
    fi
    [[ -S $socket ]] && ok "session socket still resolves after terminal death" ||
      no "session socket still resolves after terminal death"

    state=$(reader "
import { classify, readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const s = await classify(readEntries(), { sampleMs: 100, includeDirty: false });
process.stdout.write(s.find((x) => x.ledgerId === '$wrapped_id')?.state ?? 'MISSING');
")
    check "orphaned-terminal session classified DETACHED-ALIVE" "$state" "DETACHED-ALIVE"

    reopen=$(HOME="$FAKE_HOME" node "$MODULE/bin/agent-sessions" 2>/dev/null |
      grep -F "agent-sessions attach $wrapped_id" | head -1)
    [[ -n $reopen ]] && ok "recovery command prints a reopen command for it" ||
      no "recovery command prints a reopen command for it"
    [[ -n $wrapped_agent ]] && kill -9 "$wrapped_agent" 2>/dev/null
  else
    no "interactive launch is wrapped in a detachable container"
  fi
else
  printf 'skip dtach/script not available\n'
fi

# --- 8. cgroup confinement survives the wrap -------------------------------------------
if command -v dtach >/dev/null 2>&1 && [[ -x "$HOME/.claude/bin/_agent-build-scope" ]]; then
  cg_sock="$TESTROOT/cgroup.sock"
  setsid dtach -n "$cg_sock" -E "$HOME/.claude/bin/_agent-build-scope" tmpjail /bin/sleep 20 \
    </dev/null >/dev/null 2>&1
  cg_pid=""
  for _ in $(seq 1 60); do
    cg_pid=$(pgrep -u "$USER" -f '^/bin/sleep 20$' | head -1)
    [[ -n $cg_pid ]] && break
    sleep 0.1
  done
  if [[ -n $cg_pid ]]; then
    STARTED_PIDS+=("$cg_pid")
    cg=$(cat "/proc/$cg_pid/cgroup" 2>/dev/null)
    if [[ $cg == *"agent.slice"* ]]; then
      ok "wrapped session still lands in agent.slice ($(basename "$cg"))"
    else
      no "wrapped session still lands in agent.slice (got '$cg')"
    fi
    kill -9 "$cg_pid" 2>/dev/null
  else
    no "wrapped session still lands in agent.slice (process never appeared)"
  fi
fi

# --- 9. a broken ledger never blocks a launch ------------------------------------------
# The shim is the entry point for every agent on the machine: a ledger failure must cost
# the entry, never the session.
printf '#!/bin/sh\necho LAUNCHED\n' >"$REALBIN/probeagent"
chmod +x "$REALBIN/probeagent"
ln -s "$SHIMBIN/_tmpjail-shim.sh" "$SHIMBIN/probeagent"

out=$(HOME="$FAKE_HOME" PATH="$SHIMBIN:$REALBIN:$PATH" AGENT_SESSIONS_DIR=/proc/nonexistent/ledger \
  env -u TMPJAIL_ACTIVE -u DTACH "$SHIMBIN/probeagent" </dev/null 2>/dev/null)
check "launch survives an unwritable ledger directory" "$out" "LAUNCHED"

BROKEN_HOME="$TESTROOT/brokenhome"
mkdir -p "$BROKEN_HOME/.claude/lib"
printf 'if [[ \n' >"$BROKEN_HOME/.claude/lib/agent-session-ledger.sh"
out=$(HOME="$BROKEN_HOME" PATH="$SHIMBIN:$REALBIN:$PATH" \
  env -u TMPJAIL_ACTIVE -u DTACH "$SHIMBIN/probeagent" </dev/null 2>/dev/null)
check "launch survives a corrupt ledger library" "$out" "LAUNCHED"

# --- 10. a nested launch records the session that spawned it ---------------------------
NESTED_DIR="$TESTROOT/nested"
HOME="$FAKE_HOME" PATH="$SHIMBIN:$REALBIN:$PATH" AGENT_SESSIONS_DIR="$NESTED_DIR" \
  AGENT_LEDGER_ID=parent-test-id env -u TMPJAIL_ACTIVE -u DTACH \
  "$SHIMBIN/probeagent" </dev/null >/dev/null 2>&1
nested_entry=$(ls "$NESTED_DIR/sessions"/*.json 2>/dev/null | head -1)
parent=$(reader "
import { readFileSync } from 'node:fs';
process.stdout.write(String(JSON.parse(readFileSync('${nested_entry:-/dev/null}','utf8')).parentLedgerId));
" 2>/dev/null)
check "nested launch records its parent session" "$parent" "parent-test-id"

# --- 11. a control byte in a ref name still yields a parseable entry -------------------
CTRL_DIR="$TESTROOT/ctrlrepo"
CTRL_LEDGER="$TESTROOT/ctrl"
mkdir -p "$CTRL_DIR/.git"
printf 'ref: refs/heads/a\001b\n' >"$CTRL_DIR/.git/HEAD"
(
  cd "$CTRL_DIR" &&
    HOME="$FAKE_HOME" PATH="$SHIMBIN:$REALBIN:$PATH" AGENT_SESSIONS_DIR="$CTRL_LEDGER" \
      env -u TMPJAIL_ACTIVE -u DTACH "$SHIMBIN/probeagent" </dev/null >/dev/null 2>&1
)
ctrl_entry=$(ls "$CTRL_LEDGER/sessions"/*.json 2>/dev/null | head -1)
parsed=$(reader "
import { readFileSync } from 'node:fs';
try { JSON.parse(readFileSync('${ctrl_entry:-/dev/null}','utf8')); process.stdout.write('ok'); }
catch { process.stdout.write('unparseable'); }
" 2>/dev/null)
check "control byte in a ref name still yields parseable JSON" "$parsed" "ok"

# --- 12. a lookalike environment variable never resolves a session ---------------------
LOOK_DIR="$TESTROOT/lookalike"
mkdir -p "$LOOK_DIR/sessions"
look_id="claude-lookalike-000000"
printf '{"schemaVersion":1,"ledgerId":"%s","runtime":"claude","pid":1,"cwd":"%s","startedAt":"%s","finishedAt":null,"mux":{"kind":null}}\n' \
  "$look_id" "$TESTROOT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$LOOK_DIR/sessions/$look_id.json"
PARENT_AGENT_LEDGER_ID="$look_id" setsid sleep 30 </dev/null >/dev/null 2>&1 &
look_pid=$!
STARTED_PIDS+=("$look_pid")
sleep 0.3
state=$(AGENT_SESSIONS_DIR="$LOOK_DIR" reader "
import { classify, readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const s = await classify(readEntries(), { sampleMs: 50, includeDirty: false });
process.stdout.write(s.find((x) => x.ledgerId === '$look_id')?.state ?? 'MISSING');
")
check "lookalike env var does not resurrect a dead session" "$state" "ORPHANED"
kill -9 "$look_pid" 2>/dev/null

# --- 13. retention: FINISHED entries past the window are pruned, everything else survives -
RETAIN_DIR="$TESTROOT/retain"
mkdir -p "$RETAIN_DIR/sessions"
old_iso="2020-01-01T00:00:00.000Z"
recent_iso=$(date -u -d '1 day ago' +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%S.000Z)
write_entry() { # $1=ledgerId $2=finishedAt-or-empty
  local fin="null"
  [[ -n $2 ]] && fin="\"$2\""
  printf '{"schemaVersion":1,"ledgerId":"%s","runtime":"claude","cwd":"/tmp","startedAt":"%s","finishedAt":%s,"mux":{"kind":null}}\n' \
    "$1" "$old_iso" "$fin" >"$RETAIN_DIR/sessions/$1.json"
}
write_entry "old-finished" "$old_iso"
write_entry "recent-finished" "$recent_iso"
write_entry "still-live" ""
pruned=$(AGENT_SESSIONS_DIR="$RETAIN_DIR" reader "
import { pruneFinished } from '$MODULE/lib/agent-session-reader.mjs';
process.stdout.write(String(pruneFinished()));
")
check "prune removes exactly the one stale FINISHED entry" "$pruned" "1"
[[ ! -e "$RETAIN_DIR/sessions/old-finished.json" ]] && ok "stale FINISHED entry is gone" ||
  no "stale FINISHED entry is gone"
[[ -e "$RETAIN_DIR/sessions/recent-finished.json" ]] && ok "recent FINISHED entry survives" ||
  no "recent FINISHED entry survives"
[[ -e "$RETAIN_DIR/sessions/still-live.json" ]] && ok "live entry (finishedAt null) is never pruned" ||
  no "live entry (finishedAt null) is never pruned"

# --- 14. readers tolerate a session file vanishing mid-read (no crash, no fabricated entry)
VANISH_DIR="$TESTROOT/vanish"
mkdir -p "$VANISH_DIR/sessions"
printf '{"schemaVersion":1,"ledgerId":"stays","runtime":"claude","cwd":"/tmp","startedAt":"%s","finishedAt":null,"mux":{"kind":null}}\n' \
  "$old_iso" >"$VANISH_DIR/sessions/stays.json"
# A dangling symlink deterministically reproduces the readdir-then-read race: the dirent
# exists (readdirSync lists it, as it would for a file the pruner just unlinked), but the
# read that follows throws ENOENT exactly as it would on the real race.
ln -s "$VANISH_DIR/sessions/does-not-exist.json" "$VANISH_DIR/sessions/vanishes.json"
out=$(AGENT_SESSIONS_DIR="$VANISH_DIR" reader "
import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
process.stdout.write(readEntries().map((e) => e.ledgerId).join(','));
" 2>/dev/null)
check "a file removed before it's read is skipped, not fabricated or fatal" "$out" "stays"

printf '\n%s passed, %s failed\n' "$PASS" "$FAIL"
[[ $FAIL -eq 0 ]]
