Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions scripts/health.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# health.sh — the `health` shell dispatcher.
#
# Front door for the local health/dev shell tools, mirroring the Claude `/health`
# command door. Routes to the implementations defined in the sibling scripts
# (source all four via ~/.bashrc). Lives in PyAutoHeart — the Heart owns the
# health surface; PyAutoMind/scripts keeps forwarding shims for old sourcing
# paths:
#
# health cross-repo git-sync dashboard (health_sync.sh -> _health_sync)
# health sync explicit alias of the above
# health release last release-prep run dashboard (health_release.sh -> _health_release)
# health audit structural repo-health audit (health_audit.sh -> _health_audit)
# health help this usage
#
# Distinct from the `pyauto-heart` binary (the Heart organ CLI) — this is the
# local shell convenience layer. Any argument after the subcommand is passed
# through (e.g. `health release <run-dir>`).

health() {
local sub="${1:-sync}"
# Consume the subcommand token only if one was actually given, so bare
# `health` (defaults to sync) and `health <sub> <args…>` both pass the
# remaining "$@" straight through to the implementation.
[ "$#" -gt 0 ] && shift
case "$sub" in
sync) _health_sync "$@" ;;
release) _health_release "$@" ;;
audit) _health_audit "$@" ;;
-h|--help|help)
cat <<'EOF'
health — local health/dev shell dispatcher (mirrors the Claude /health door).

Usage: health [sync|release|audit]

health cross-repo git-sync dashboard (branch, behind/ahead, dirty)
health sync same as bare `health`
health release last PyAutoBuild release-prep run dashboard
health audit structural repo-health audit (non-repo dirs, stashes, dead branches)

Release-run helpers: health-report / health-json / health-triage.
EOF
;;
*)
echo "health: unknown subcommand '$sub' (try: health help)" >&2
return 2
;;
esac
}
113 changes: 113 additions & 0 deletions scripts/health_audit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# health_audit.sh — on-demand structural repo-health audit.
#
# Defines `_health_audit` (run via `health audit`) that scans ~/Code/PyAutoLabs/
# for state the git-sync dashboard (`health` / `health sync`) doesn't surface:
#
# 1. Top-level directories with no .git (intentionally-not-a-repo or bug).
# Skip prefixes "." (hidden) and "z_" (user's personal/staging convention).
# 2. Stashes older than $PYAUTO_AUDIT_STASH_DAYS (default 14) — drift-from-
# stash is a real failure mode.
# 3. Local-only branches with no upstream and last commit older than
# $PYAUTO_AUDIT_BRANCH_DAYS (default 30) — likely abandoned work.
#
# Run on demand. Always exits 0 — informational, the user reads + decides.
#
# Usage (normally sourced via ~/.bashrc, run through the `health` dispatcher):
# source ~/Code/PyAutoLabs/PyAutoMind/scripts/health_audit.sh
# health audit
#
# Override via env vars:
# PYAUTO_AUDIT_ROOT scan root (default $HOME/Code/PyAutoLabs)
# PYAUTO_AUDIT_STASH_DAYS stash age threshold in days (default 14)
# PYAUTO_AUDIT_BRANCH_DAYS branch age threshold in days (default 30)

PYAUTO_AUDIT_ROOT="${PYAUTO_AUDIT_ROOT:-$HOME/Code/PyAutoLabs}"
PYAUTO_AUDIT_STASH_DAYS="${PYAUTO_AUDIT_STASH_DAYS:-14}"
PYAUTO_AUDIT_BRANCH_DAYS="${PYAUTO_AUDIT_BRANCH_DAYS:-30}"

_health_audit() {
local root="$PYAUTO_AUDIT_ROOT"
if [[ ! -d "$root" ]]; then
echo "health audit: $root does not exist" >&2
return 1
fi

local now stash_thresh branch_thresh
now=$(date +%s)
stash_thresh=$(( PYAUTO_AUDIT_STASH_DAYS * 86400 ))
branch_thresh=$(( PYAUTO_AUDIT_BRANCH_DAYS * 86400 ))

# Section 1: non-git directories. Skip prefixes are hardcoded — if the
# legitimate exceptions ever exceed 3-4 patterns, switch to a snooze file.
local skip_prefixes=("." "z_")
local non_git=() dir name skip prefix
for dir in "$root"/*/; do
[[ ! -d "$dir" ]] && continue
name="$(basename "$dir")"
skip=false
for prefix in "${skip_prefixes[@]}"; do
[[ "$name" == "$prefix"* ]] && skip=true && break
done
[[ "$skip" == "true" ]] && continue
[[ -e "$dir.git" ]] && continue
non_git+=("$name")
done

# Section 2: old stashes. `--format='%gd|%ad|%ct|%s'` gives the stash ref,
# short date, commit timestamp, and subject in one line per entry.
local stash_lines=() repo line ref short_date ts subj age
for dir in "$root"/*/.git; do
[[ -e "$dir" ]] || continue
repo="${dir%/.git}"
name="$(basename "$repo")"
while IFS='|' read -r ref short_date ts subj; do
[[ -z "$ts" ]] && continue
age=$(( now - ts ))
(( age < stash_thresh )) && continue
stash_lines+=("$name: $ref ($short_date) $subj")
done < <(git -C "$repo" stash list --date=short --format='%gd|%ad|%ct|%s' 2>/dev/null)
done

# Section 3: abandoned local-only branches. `for-each-ref` with empty
# `%(upstream)` filters local-only branches, then we age-filter on commit
# timestamp. Pipe delimiter (not tab) because bash treats consecutive
# whitespace IFS chars as one separator, which would collapse the empty
# upstream column into the timestamp.
local branch_lines=() branch upstream branch_ts iso
for dir in "$root"/*/.git; do
[[ -e "$dir" ]] || continue
repo="${dir%/.git}"
name="$(basename "$repo")"
while IFS='|' read -r branch upstream branch_ts; do
[[ -z "$branch" ]] && continue
[[ -n "$upstream" ]] && continue
age=$(( now - branch_ts ))
(( age < branch_thresh )) && continue
iso=$(date -d "@$branch_ts" +%Y-%m-%d 2>/dev/null)
branch_lines+=("$name: $branch (last: $iso)")
done < <(git -C "$repo" for-each-ref --format='%(refname:short)|%(upstream)|%(committerdate:unix)' refs/heads/ 2>/dev/null)
done

# Output. Sections suppressed when empty; each prints its own header.
local printed=false
if (( ${#non_git[@]} > 0 )); then
echo "Non-git directories under $root:"
for name in "${non_git[@]}"; do echo " $name/"; done
printed=true
fi
if (( ${#stash_lines[@]} > 0 )); then
[[ "$printed" == "true" ]] && echo ""
echo "Old stashes (>$PYAUTO_AUDIT_STASH_DAYS days):"
for line in "${stash_lines[@]}"; do echo " $line"; done
printed=true
fi
if (( ${#branch_lines[@]} > 0 )); then
[[ "$printed" == "true" ]] && echo ""
echo "Abandoned local-only branches (no upstream, last commit >$PYAUTO_AUDIT_BRANCH_DAYS days):"
for line in "${branch_lines[@]}"; do echo " $line"; done
printed=true
fi
[[ "$printed" == "false" ]] && echo "health audit: clean (no findings under $root)"
return 0
}
215 changes: 215 additions & 0 deletions scripts/health_release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
#!/usr/bin/env bash
# health_release.sh — release-prep run dashboard.
#
# Defines `_health_release` (run via `health release`) that reads the latest
# PyAutoBuild full release-prep run (the one symlinked from
# PyAutoBuild/test_results/latest/) and prints a dashboard:
#
# - Run timestamp + path + ready/not-ready verdict + total duration
# - Per-workspace pass / fail / skipped / timeout / duration table
# - Failure counts grouped by classification
# - Top-25 slowest scripts (any status) — surfaces timing regressions
# before they cross the timeout threshold
# - Slow-skip / needs-fix banner counts
# - Pointer to triage.md if present (free-form analytical clustering)
#
# Usage (normally sourced via ~/.bashrc, run through the `health` dispatcher):
# source ~/Code/PyAutoLabs/PyAutoMind/scripts/health_release.sh
# health release
#
# Override the run path (e.g. to inspect a specific historical run) by passing
# it as the first argument:
# health release ~/Code/PyAutoLabs/PyAutoBuild/test_results/runs/2026-04-29T14-48-47Z
#
# Sibling helpers: health-report / health-json / health-triage open the last
# run's report.md / report.json / triage.md.
#
# Note: distinct from the Claude `/health full` command (the conversational
# layer over the same run artefacts). This function prints straight to stdout,
# no Claude needed.

PYAUTO_STATUS_FULL_DEFAULT="${PYAUTO_STATUS_FULL_DEFAULT:-$HOME/Code/PyAutoLabs/PyAutoBuild/test_results/latest}"

_health_release() {
local run_dir="${1:-$PYAUTO_STATUS_FULL_DEFAULT}"

if [[ ! -e "$run_dir" ]]; then
cat >&2 <<EOF
health release: no run found at $run_dir

To produce one, from PyAutoBuild root:
source ../activate.sh
python autobuild/run_all.py
EOF
return 1
fi

# Resolve symlink so the printed path is the actual run dir.
run_dir="$(readlink -f "$run_dir")"

local report_json="$run_dir/report.json"
if [[ ! -f "$report_json" ]]; then
echo "health release: $report_json missing — run incomplete?" >&2
return 1
fi

python3 - "$run_dir" <<'PY'
import json
import sys
from pathlib import Path

run_dir = Path(sys.argv[1])
with open(run_dir / "report.json") as f:
r = json.load(f)

ready = r.get("ready")
total = float(r.get("total_duration_seconds", 0.0) or 0.0)
summary = r.get("summary", {}) or {}
n_pass = summary.get("passed", 0)
n_fail = summary.get("failed", 0)
n_skip = summary.get("skipped", 0)
n_to = summary.get("timeout", 0)

GREEN = "\033[32m"
RED = "\033[31m"
YEL = "\033[33m"
DIM = "\033[2m"
RST = "\033[0m"

verdict = f"{GREEN}READY{RST}" if ready else f"{RED}NOT READY{RST}"
print(f"{'=' * 76}")
print(f" PyAuto Status Full")
print(f"{'=' * 76}")
print(f"Run: {r.get('run_label','')}")
print(f"Path: {run_dir}")
print(f"Status: {verdict} (passed: {n_pass}, failed: {n_fail}, skipped: {n_skip}, timeout: {n_to})")
print(f"Total: {total:.1f}s ({total/60:.1f} min)")
print()

# Per-workspace
print("Per-workspace")
print("-" * 76)
print(f"{'Workspace':<22} {'Passed':>6} {'Failed':>6} {'Skipped':>7} {'Timeout':>7} {'Duration':>10}")
pp = r.get("per_project", {}) or {}
ppd = r.get("per_project_duration_seconds", {}) or {}
for proj in sorted(pp.keys()):
c = pp[proj]
f = c.get("failed", 0)
t = c.get("timeout", 0)
color = GREEN if (f == 0 and t == 0) else RED
print(
f"{color}{proj:<22}{RST} "
f"{c.get('passed',0):>6} {f:>6} "
f"{c.get('skipped',0):>7} {t:>7} "
f"{ppd.get(proj,0):>9.1f}s"
)
print()

# Failures by classification
failures = r.get("failures", []) or []
if failures:
by_class = {}
for fr in failures:
cls = fr.get("classification", "unknown")
by_class.setdefault(cls, []).append(fr)
labels = {
"source_code_bug": "Source code bugs",
"workspace_issue": "Workspace issues",
"workspace_data": "Missing data files",
"environment": "Environment issues",
"timeout": "Timeouts",
"known_numerical": "Known numerical",
"unknown": "Unclassified",
}
print(f"Failures by classification ({len(failures)} total)")
print("-" * 76)
for cls in sorted(by_class.keys(), key=lambda c: -len(by_class[c])):
items = by_class[cls]
print(f" {labels.get(cls, cls):<22} {len(items)}")
print()

# Slowest 25
slowest = r.get("slowest", []) or []
if slowest:
print(f"Slowest {len(slowest)} scripts")
print("-" * 76)
print(f"{'Duration':>9} {'Status':<8} {'Project':<16} Script")
for s in slowest:
proj = s.get("project", "")
stat = s.get("status", "")
fil = s.get("file", "")
# Trim absolute paths to last 3 segments for readability.
short = "/".join(fil.split("/")[-3:])
dur = float(s.get("duration_seconds", 0.0) or 0.0)
color = RED if stat in ("failed", "timeout") else (YEL if dur > 180 else "")
print(f"{color}{dur:>8.1f}s {stat:<8} {proj:<16} {short}{RST}")
print()

# Parked scripts banners
slow_skips = r.get("slow_skips") or []
nf_skips = r.get("needs_fix_skips") or []
if slow_skips or nf_skips:
print("Parked scripts (workspace no_run.yaml banners)")
print("-" * 76)
if slow_skips:
print(f" SLOW skips: {len(slow_skips)} (need performance fix)")
if nf_skips:
print(f" NEEDS_FIX skips: {len(nf_skips)} (parked broken)")
print()

# Pointers
print("Pointers")
print("-" * 76)
print(f" Markdown report: {run_dir}/report.md {DIM}(health-report){RST}")
print(f" Run JSON: {run_dir}/report.json {DIM}(health-json){RST}")
triage = run_dir / "triage.md"
if triage.exists():
print(f" {GREEN}Triage notes: {triage}{RST} {DIM}(health-triage){RST}")
PY
}

# _pyauto_run_file <subpath> [run-dir-arg] — resolve a file inside the latest
# (or supplied) run directory. Used by the pyauto-{report,json,triage} viewers.
Comment on lines +171 to +172
_pyauto_run_file() {
local subpath="$1"
local run_dir="${2:-$PYAUTO_STATUS_FULL_DEFAULT}"

if [[ ! -e "$run_dir" ]]; then
echo "pyauto: no run found at $run_dir" >&2
return 1
fi
Comment on lines +177 to +180
run_dir="$(readlink -f "$run_dir")"

local target="$run_dir/$subpath"
if [[ ! -f "$target" ]]; then
echo "pyauto: $target missing" >&2
return 1
fi
Comment on lines +183 to +187
printf '%s' "$target"
}

# health-report [run-dir] — view report.md in the pager.
health-report() {
local f
f="$(_pyauto_run_file report.md "$1")" || return 1
"${PAGER:-less}" "$f"
}

# health-json [run-dir] — view report.json. Uses jq for color + paging when
# available, falls back to plain cat otherwise.
health-json() {
local f
f="$(_pyauto_run_file report.json "$1")" || return 1
if command -v jq >/dev/null 2>&1; then
jq -C . "$f" | "${PAGER:-less}" -R
else
"${PAGER:-less}" "$f"
fi
}

# health-triage [run-dir] — view triage.md in the pager.
health-triage() {
local f
f="$(_pyauto_run_file triage.md "$1")" || return 1
"${PAGER:-less}" "$f"
}
Loading
Loading