Compare commits

..
38 Commits
Author SHA1 Message Date
Gmer4Lfe 5fa7750046 Sonarr and Lidarr had the same deadlock and the same missing confirmation as Radarr, so give them the same budget and strikes 2026-08-26 21:20:05 -04:00
Gmer4Lfe 1838eed855 Give AI_ASSIST_CLEANUP the consumer it has never had: it describes the shape of a classification and decides nothing, so switching it off changes no deletion 2026-08-26 18:03:17 -04:00
Gmer4Lfe 96d8a5e3f0 Make a file earn its deletion over consecutive runs, so a partial classification failure too small to trip the tracked-count gate cannot remove anything 2026-08-26 17:58:42 -04:00
Gmer4Lfe 0431e720de A cap that aborts cannot drain a backlog bigger than itself, so make it a per-run budget and let the queue clear over consecutive nights 2026-08-26 17:55:21 -04:00
Gmer4Lfe 7f4921de49 Give the wizard an assistant scoped to the step you are actually stuck on, so a first-run question can say "this" and mean something 2026-08-26 17:31:52 -04:00
Gmer4Lfe d4c19baa32 Folding the Scheduler dock into the shared chat left its scope behind, so the troubleshooter has been diagnosing without the log tail for whatever was open 2026-08-26 17:25:28 -04:00
Gmer4Lfe d444fd8036 A path with an apostrophe broke the remote shell quoting and a big file outran the connect timeout, so intact backups were reported corrupt and absent 2026-08-25 21:30:57 -04:00
Gmer4Lfe d42b1e2dda ffprobe exit 0 with a chapter-track warning is not corruption; guard the delete path against a detector that is wrong at scale 2026-08-25 18:29:43 -04:00
Gmer4Lfe 9492dc4c39 dig names the resolver it could not reach in its error text, so a DNS timeout was scraped as the answer and restarted DDNS over nothing 2026-08-25 18:29:43 -04:00
Gmer4Lfe 172beca3c5 A state key is a file path, not a regex — a release tag like [Bluray-1080p] holds an invalid range, so grep bailed and the tempfile swap wiped every other entry 2026-08-25 18:29:43 -04:00
Gmer4Lfe f0c1289519 Let the file own why the row cap is conditional; the README states that it is 2026-08-25 17:21:20 -04:00
Gmer4Lfe 128172d3a8 The row counts below four columns were the naive division, not what the board does 2026-08-25 17:21:20 -04:00
Gmer4Lfe 820e8325a6 Docs: the row-height cap is conditional now, and the board's narrow-screen behaviour was nowhere in the page help 2026-08-25 17:15:20 -04:00
Gmer4Lfe 74c6a0f5eb Monitor: hold the System card to the Network card's height below eight columns, where the two stop sharing a row 2026-08-25 17:07:18 -04:00
Gmer4Lfe d289a9101c Monitor: cap the System card's Varaverk block so the host figures set the card's height, not the sum of both 2026-08-25 17:01:52 -04:00
Gmer4Lfe 9553ecb16a The two functions the row-overflow hatch added were not in EXPORTS 2026-08-25 17:01:10 -04:00
Gmer4Lfe fb104bf05a Monitor: a rung taller than one screen sizes rows to content, since the page scrolls there anyway and the cap only clipped cards 2026-08-25 16:54:19 -04:00
Gmer4Lfe f4adc31215 Close the last four headers, and say what the destructive tools refuse to do 2026-08-25 16:51:14 -04:00
Gmer4Lfe c6254f2342 The dev checkers documented themselves under headings nothing else uses 2026-08-25 16:45:39 -04:00
Gmer4Lfe d25b147a56 A CLI tool has runtime modes, not a request 2026-08-25 16:42:39 -04:00
Gmer4Lfe 1f9915f074 Fill the single missing section in three scripts that had everything else 2026-08-25 16:41:06 -04:00
Gmer4Lfe c0e7a97a56 Document what the cert and auth sweeps will not do 2026-08-25 16:39:01 -04:00
Gmer4Lfe 68c835f33b Document what the proxy and cert collectors guarantee 2026-08-25 16:37:25 -04:00
Gmer4Lfe 6b9fda76db Say how the spawned tools are actually invoked 2026-08-25 16:34:02 -04:00
Gmer4Lfe 1be259b66d Two AI includes documented themselves under names nothing else uses 2026-08-25 16:31:43 -04:00
Gmer4Lfe 2c3c88d067 Say what the include layer exports and what it reads from conf 2026-08-25 16:29:57 -04:00
Gmer4Lfe 3fc14d16bf A worker that is spawned has arguments, not runtime modes 2026-08-25 16:25:55 -04:00
Gmer4Lfe 4cc50889d6 Document the api layer's operational models, and fix two headers that had gone stale against the code 2026-08-25 16:25:55 -04:00
Gmer4Lfe 63e68740b6 Put the header sections back in the order the other 116 scripts use 2026-08-25 16:19:19 -04:00
Gmer4Lfe f2fddeb87d The reboot path's container stops were unbounded, and stopped a different list than it restored 2026-08-24 20:22:55 -04:00
Gmer4Lfe eda411c0be Give the PHP layer somewhere to record what it swallowed 2026-08-24 18:51:36 -04:00
Gmer4Lfe 492757ce70 Walk appdata once for the space check, and refuse to export when it cannot be measured 2026-08-24 18:46:26 -04:00
Gmer4Lfe b58282b3d6 Read a filesystem once where two probes were answering the same question 2026-08-24 18:46:26 -04:00
Gmer4Lfe 9da2760a42 A localStorage guard was covering the layout restore it wrapped 2026-08-24 18:42:34 -04:00
Gmer4Lfe 049f633667 Bound the watchdogs' docker calls — a hung daemon is what they exist to catch 2026-08-24 18:39:59 -04:00
Gmer4Lfe 84946ed0c6 clear_logs previewed on one size basis and cleared on another 2026-08-24 18:36:29 -04:00
Gmer4Lfe 514e13660c Give the bash side the disk helpers it never had, reporting the same unit as vv_df 2026-08-24 18:36:29 -04:00
Gmer4Lfe 9333334b7b Bound the two tailscale lookups that were missed when the others were 2026-08-24 18:33:40 -04:00
76 changed files with 1965 additions and 365 deletions
+25
View File
@@ -30,6 +30,31 @@
# fetches its own data controls its own freshness rather than depending on the partner's cron.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# A missing file means unknown, never zero.
# The whole point of the tab reporting "not collected here" is that it is a different claim
# from "this partner spent nothing". If a partner is dark, unreachable or has never run a turn,
# there is simply no cache file, and the reader is expected to say so rather than render a 0
# that looks like a measurement.
#
# The reader pulls; nobody pushes.
# conf_sync.sh pushes as well, because a partner that cannot reach us still needs our
# credentials. Nothing here is needed by anyone else, so a host that wants fleet totals fetches
# them and owns its own freshness instead of depending on someone else's cron having run.
#
# RAM, not flash.
# The cache lands in tmpfs. It is a copy of a file that already exists on the partner and is
# rebuilt on the next pass, so writing it to flash would cost wear for something that is never
# worth surviving a reboot.
#
# Same shape as conf_sync.sh, deliberately.
# Resolve over Tailscale, scp one small file into a tmpfs cache, let a missing file mean
# unknown. A second transport pattern for a second small file would be a second set of
# failure modes to learn.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
+120 -8
View File
@@ -273,6 +273,9 @@ touch "$CORRUPTION_SCAN_STATE_FILE"
CORRUPTION_SCAN_STRIKES_FILE="${CORRUPTION_SCAN_STRIKES_FILE:-$DATA_DIR/corruption_scan_strikes.tsv}"
CORRUPTION_SCAN_STRIKE_LIMIT="${CORRUPTION_SCAN_STRIKE_LIMIT:-2}"
CORRUPTION_SCAN_MAX_CORRUPT_PCT="${CORRUPTION_SCAN_MAX_CORRUPT_PCT:-10}"
CORRUPTION_SCAN_MAX_CONSECUTIVE="${CORRUPTION_SCAN_MAX_CONSECUTIVE:-15}"
CORRUPTION_SCAN_GUARD_MIN_SCANNED="${CORRUPTION_SCAN_GUARD_MIN_SCANNED:-20}"
mkdir -p "$(dirname "$CORRUPTION_SCAN_STRIKES_FILE")"
touch "$CORRUPTION_SCAN_STRIKES_FILE"
@@ -301,6 +304,19 @@ reset_scan_strikes() {
[[ -n "$current" && "$current" != "0" ]] && set_scan_strikes "$1" 0
}
# Bails out of the whole run without committing anything. Safe to call at any point before
# the commit phase: strikes are queued in memory until then, so an abort leaves the strike
# file exactly as the previous run left it and deletes nothing.
abort_scan() {
local why="$1"
error "Corruption scan ABORTED — $why"
error "No strikes recorded and nothing remediated this run — the library was not trusted."
[[ -n "${FRESH_CLEAN_TMP:-}" ]] && rm -f "$FRESH_CLEAN_TMP"
notify "Corruption scan aborted on $(hostname) ($MY_ID) — $why. Nothing deleted." \
"Arr Corruption Scan" "warning"
exit 1
}
# Per-arr API shape differences — everything else in the scan/strike/remediate loop below is
# identical between Sonarr and Radarr.
declare -A ARR_FILE_ENDPOINT=( [sonarr]="episodefile" [radarr]="moviefile" )
@@ -322,6 +338,8 @@ if [[ "$SHOW_STATUS" == true ]]; then
echo "$ICON_GEAR State file: $CORRUPTION_SCAN_STATE_FILE"
echo "$ICON_GEAR Strike limit: $CORRUPTION_SCAN_STRIKE_LIMIT"
echo "$ICON_GEAR Remediate: $REMEDIATE"
echo "$ICON_GEAR Corrupt ceiling: ${CORRUPTION_SCAN_MAX_CORRUPT_PCT}% of scanned (min ${CORRUPTION_SCAN_GUARD_MIN_SCANNED} scanned)"
echo "$ICON_GEAR Consecutive trip: $CORRUPTION_SCAN_MAX_CONSECUTIVE"
echo "$ICON_GEAR Scan limit: ${SCAN_LIMIT:-unlimited} (per arr)"
echo "$ICON_GEAR Path filter: ${PATH_FILTER:-none}"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
@@ -357,13 +375,36 @@ ffprobe_translate_path() {
# Probes one file. Echoes "clean" or "corrupt:<reason>". Never trusts a truncated/garbled
# stderr as automatically corrupt — only a real non-empty ffprobe stderr counts.
probe_file() {
local host_path="$1" container_path output
local host_path="$1" container_path output rc
container_path=$(ffprobe_translate_path "$host_path") || { echo "unmapped"; return; }
output=$(docker exec "$FFPROBE_CONTAINER" "$FFPROBE_BIN" -v error "$container_path" 2>&1)
rc=$?
# docker exec writes its own failures to the same stream ffprobe uses, so a stopped
# container or an unreachable daemon is otherwise indistinguishable from a corrupt
# header. A stopped container exits 1 with a daemon message; a missing binary exits
# 127 — neither is evidence about the file, so both must be caught.
if (( rc >= 125 )) \
|| [[ "$output" == "Error response from daemon:"* \
|| "$output" == "Cannot connect to the Docker daemon"* \
|| "$output" == "error during connect:"* ]]; then
echo "probe_error:${output//$'\n'/ }"
return
fi
if [[ -z "$output" ]]; then
echo "clean"
else
elif (( rc != 0 )); then
# ffprobe could not parse the file — EBML header parsing failed, moov atom not found,
# contradictionary STSC and STCO. This is the only class that may be remediated.
echo "corrupt:${output//$'\n'/ }"
else
# Exit 0 with stderr output: a recoverable muxing complaint, most commonly
# "Referenced QT chapter track not found", which many recent .mp4 releases emit and
# which says nothing about playability. Equating any stderr with corruption is what
# produced 103 "corrupt" files on 2026-08-23 — 28 of 43 newly scanned Radarr items.
# Reported for visibility, never strike-tracked, never remediated.
echo "suspect:${output//$'\n'/ }"
fi
}
@@ -442,7 +483,7 @@ TOTAL_SCANNED=0
TOTAL_CORRUPT=0
TOTAL_REMEDIATED=0
TOTAL_REMEDIATE_FAILED=0
declare -A ARR_SCANNED ARR_SKIPPED_CACHED ARR_SKIPPED_UNMAPPED ARR_CORRUPT ARR_STRIKE_HELD ARR_REMEDIATED ARR_REMEDIATE_FAILED
declare -A ARR_SCANNED ARR_SKIPPED_CACHED ARR_SKIPPED_UNMAPPED ARR_CORRUPT ARR_SUSPECT ARR_PROBE_ERRORS ARR_STRIKE_HELD ARR_REMEDIATED ARR_REMEDIATE_FAILED
for arr in sonarr radarr; do
url_var="${arr^^}_URL"; key_var="${arr^^}_API_KEY"
@@ -532,6 +573,12 @@ for arr in sonarr radarr; do
STRIKE_HELD=0
REMEDIATED=0
REMEDIATE_FAILED=0
PROBE_ERRORS=0
SUSPECT_COUNT=0
CONSECUTIVE_BAD=0
QUEUE_PATH=()
QUEUE_STRIKES=()
QUEUE_ITEM=()
FRESH_CLEAN_TMP=$(mktemp)
@@ -566,23 +613,82 @@ for arr in sonarr radarr; do
continue
fi
# A docker-level failure is not evidence about the file. Count it, never queue it.
if [[ "$result" == probe_error:* ]]; then
(( PROBE_ERRORS++ ))
(( CONSECUTIVE_BAD++ ))
warn " ? $host_path — probe failed, NOT counted as corrupt: ${result#probe_error:}"
if (( CONSECUTIVE_BAD >= CORRUPTION_SCAN_MAX_CONSECUTIVE )); then
abort_scan "$CONSECUTIVE_BAD files in a row failed to probe cleanly"
fi
continue
fi
if [[ "$result" == "clean" ]]; then
CONSECUTIVE_BAD=0
reset_scan_strikes "$host_path"
echo -e "${host_path}\t${stamp}" >> "$FRESH_CLEAN_TMP"
[[ "$ENABLE_LOGGING" == true ]] && echo " $ICON_SUCCESS $host_path"
continue
fi
# corrupt:<reason>
# A successful probe that merely warned. Proves the container is alive, so it clears
# the consecutive-failure tripwire, but it never becomes a strike.
if [[ "$result" == suspect:* ]]; then
CONSECUTIVE_BAD=0
(( SUSPECT_COUNT++ ))
[[ "$ENABLE_LOGGING" == true ]] && warn " ~ $host_path — ffprobe warning (exit 0), NOT corrupt: ${result#suspect:}"
continue
fi
# corrupt:<reason> — queued, NOT committed. Nothing reaches the strike file and nothing
# is deleted until this arr has been fully probed and the guards below have passed. A
# container that dies mid-scan makes every remaining file read as corrupt, and a delete
# cannot be undone — so the destructive half has to wait until the corrupt rate for the
# whole run is known. 2026-08-23: one Jellyfin restart produced 103 false positives.
reason="${result#corrupt:}"
(( CORRUPT_COUNT++ ))
strikes=$(increment_scan_strikes "$host_path")
(( CONSECUTIVE_BAD++ ))
prev_strikes=$(get_scan_strikes "$host_path")
prev_strikes="${prev_strikes//[^0-9]/}"
strikes=$(( ${prev_strikes:-0} + 1 ))
QUEUE_PATH+=("$host_path")
QUEUE_STRIKES+=("$strikes")
QUEUE_ITEM+=("$item")
echo " $ICON_ERROR CORRUPT: $host_path (strike $strikes/$CORRUPTION_SCAN_STRIKE_LIMIT)"
[[ "$ENABLE_LOGGING" == true ]] && echo " $reason"
if [[ "$REMEDIATE" != true ]]; then
continue
if (( CONSECUTIVE_BAD >= CORRUPTION_SCAN_MAX_CONSECUTIVE )); then
abort_scan "$CONSECUTIVE_BAD files in a row failed to probe cleanly"
fi
done < <(echo "$ITEMS" | jq -c '.[]')
# ━━━ False-positive guards — run before anything is committed ━━━
if (( CORRUPT_COUNT > 0 )); then
# The pre-flight check only proves the container was up when the scan started.
# Re-check now: a mid-scan death is exactly what this guard exists to catch.
check_container_health "$FFPROBE_CONTAINER" "${DOCKER_TIMEOUT:-30}" "Arr Corruption Scan"
if (( SCANNED >= CORRUPTION_SCAN_GUARD_MIN_SCANNED )); then
corrupt_pct=$(( CORRUPT_COUNT * 100 / SCANNED ))
if (( corrupt_pct >= CORRUPTION_SCAN_MAX_CORRUPT_PCT )); then
abort_scan "$CORRUPT_COUNT of $SCANNED probed files (${corrupt_pct}%) read as corrupt — at or above the ${CORRUPTION_SCAN_MAX_CORRUPT_PCT}% ceiling"
fi
fi
fi
# ━━━ Guards passed — commit strikes, then remediate whatever reached the limit ━━━
for _q in "${!QUEUE_PATH[@]}"; do
host_path="${QUEUE_PATH[$_q]}"
strikes="${QUEUE_STRIKES[$_q]}"
item="${QUEUE_ITEM[$_q]}"
set_scan_strikes "$host_path" "$strikes"
[[ "$REMEDIATE" != true ]] && continue
if (( strikes < CORRUPTION_SCAN_STRIKE_LIMIT )); then
warn " $host_path — strike $strikes/$CORRUPTION_SCAN_STRIKE_LIMIT, not yet remediating (needs repeat confirmation)"
@@ -591,6 +697,8 @@ for arr in sonarr radarr; do
fi
reset_scan_strikes "$host_path"
file_id=$(echo "$item" | jq -r '.file_id')
parent_id=$(echo "$item" | jq -r '.parent_id')
title=$(echo "$item" | jq -r '.title')
http_code=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
@@ -625,7 +733,7 @@ for arr in sonarr radarr; do
warn " $title — deleted and verified, but search trigger returned HTTP $search_code"
(( REMEDIATE_FAILED++ ))
fi
done < <(echo "$ITEMS" | jq -c '.[]')
done
merge_clean_state "$FRESH_CLEAN_TMP"
rm -f "$FRESH_CLEAN_TMP"
@@ -634,6 +742,8 @@ for arr in sonarr radarr; do
ARR_SKIPPED_CACHED[$arr]=$SKIPPED_CACHED
ARR_SKIPPED_UNMAPPED[$arr]=$SKIPPED_UNMAPPED
ARR_CORRUPT[$arr]=$CORRUPT_COUNT
ARR_SUSPECT[$arr]=$SUSPECT_COUNT
ARR_PROBE_ERRORS[$arr]=$PROBE_ERRORS
ARR_STRIKE_HELD[$arr]=$STRIKE_HELD
ARR_REMEDIATED[$arr]=$REMEDIATED
ARR_REMEDIATE_FAILED[$arr]=$REMEDIATE_FAILED
@@ -658,6 +768,8 @@ for arr in sonarr radarr; do
echo " $ICON_SUCCESS Skipped (cached): ${ARR_SKIPPED_CACHED[$arr]}"
echo " $ICON_WARN Skipped (unmapped): ${ARR_SKIPPED_UNMAPPED[$arr]}"
echo " $ICON_ERROR Corrupt found: ${ARR_CORRUPT[$arr]}"
echo " $ICON_WARN Warnings (exit 0): ${ARR_SUSPECT[$arr]} (reported, never remediated)"
echo " $ICON_WARN Probe errors: ${ARR_PROBE_ERRORS[$arr]} (not counted as corrupt)"
if [[ "$REMEDIATE" == true ]]; then
echo " $ICON_WARN Held (strikes): ${ARR_STRIKE_HELD[$arr]}"
echo " $ICON_SUCCESS Remediated: ${ARR_REMEDIATED[$arr]}"
+1 -2
View File
@@ -354,8 +354,7 @@ for arr in sonarr radarr lidarr; do
big_unknown=$((big_unknown + 1))
done < <(find "$entry" -type f -size +"${min_mb}"M 2>/dev/null)
size_mb=$(du -sm "$entry" 2>/dev/null | cut -f1)
size_mb=${size_mb:-0}
size_mb=$(dir_size_mb "$entry") || size_mb=0
if [[ "$has_media" == false ]] && (( big_unknown > 0 )); then
warn " no recognised media, but $big_unknown large file(s) of unknown type — holding: $base"
Executable → Regular
+70 -8
View File
@@ -457,6 +457,30 @@ NOW=$(date +%s)
TO_DELETE_FILE="$TMP_DIR/to_delete_paths.txt"
> "$TO_DELETE_FILE"
# ── Orphan strikes ────────────────────────────────────────────────────────────────────────────
# Same contract as radarr_cleanup.sh: a file must classify for deletion on
# LIDARR_ORPHAN_STRIKE_LIMIT consecutive runs before it is removed. Covers the partial
# classification failure that is too small to trip the tracked-count floor above. The file is
# rebuilt from each run rather than edited, which is what prunes it.
LIDARR_ORPHAN_STRIKE_LIMIT="${LIDARR_ORPHAN_STRIKE_LIMIT:-2}"
STRIKES_FILE="${LIDARR_ORPHAN_STRIKES_FILE:-$DB_DIR/lidarr_orphan_strikes.tsv}"
mkdir -p "$(dirname "$STRIKES_FILE")" 2>/dev/null || true
touch "$STRIKES_FILE" 2>/dev/null || true
STRIKES_NEW="$TMP_DIR/strikes_new.tsv"
> "$STRIKES_NEW"
HELD_COUNT=0
HELD_BYTES=0
orphan_strike_ok() {
local path="$1" prev strikes
prev=$(wd_state_get "$path" "$STRIKES_FILE"); prev="${prev//[^0-9]/}"
strikes=$(( ${prev:-0} + 1 ))
printf '%s:%s\n' "$path" "$strikes" >> "$STRIKES_NEW"
(( strikes >= LIDARR_ORPHAN_STRIKE_LIMIT )) && return 0
warn " strike $strikes/$LIDARR_ORPHAN_STRIKE_LIMIT — not removing yet: $path"
return 1
}
while read -r FILE_SIZE FILE_CTIME filepath; do
[[ -z "$filepath" ]] && continue
FILE_CTIME="${FILE_CTIME%%.*}"
@@ -493,12 +517,14 @@ while read -r FILE_SIZE FILE_CTIME filepath; do
warn "$ICON_TRASH ORPHAN: $filepath"
(( ORPHAN_COUNT++ ))
ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE ))
echo "$filepath" >> "$TO_DELETE_FILE"
if ! orphan_strike_ok "$filepath"; then (( HELD_COUNT++ )); HELD_BYTES=$(( HELD_BYTES + FILE_SIZE )); continue; fi
printf '%s\t%s\t%s\n' "$FILE_SIZE" "$FILE_CTIME" "$filepath" >> "$TO_DELETE_FILE"
else
log "JUNK: $filepath"
(( JUNK_COUNT++ ))
JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE ))
echo "$filepath" >> "$TO_DELETE_FILE"
if ! orphan_strike_ok "$filepath"; then (( HELD_COUNT++ )); HELD_BYTES=$(( HELD_BYTES + FILE_SIZE )); continue; fi
printf '%s\t%s\t%s\n' "$FILE_SIZE" "$FILE_CTIME" "$filepath" >> "$TO_DELETE_FILE"
fi
# -printf gets size + mtime directly from find's own stat() during the walk, instead of a
@@ -506,13 +532,43 @@ while read -r FILE_SIZE FILE_CTIME filepath; do
# 4.3ms), since find already has to stat() every entry anyway to know it's -type f.
done < <(find "$LIDARR_MUSIC_ROOT" -type f -printf '%s %C@ %p\n' 2>/dev/null)
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT ))
# Eligible, not classified: a file still serving its strikes is an orphan but is not queued this
# run, so it must not appear in the denominator the budget reports against.
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES - HELD_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT - HELD_COUNT ))
# Rebuilt, never edited. Skipped on a dry run: a preview that advanced real counters would make
# the next real run delete a run early.
if [[ "$DRY_RUN" == false ]]; then
mv "$STRIKES_NEW" "$STRIKES_FILE" 2>/dev/null || warn "Could not update $STRIKES_FILE"
fi
# ==============================================================================================
# ━━━ Safety Layer 7 — Deletion Size Threshold ━━━
# ==============================================================================================
check_delete_size_threshold "$TOTAL_DELETE_BYTES" "$LIDARR_MAX_DELETE_GB" "Lidarr Cleanup"
# A per-run budget, not a veto — see apply_delete_budget() in common.sh. The ceiling still caps
# any single run; it just no longer deadlocks on a backlog larger than itself.
BUDGET_FILE="$TMP_DIR/to_delete_budgeted.txt"
if [[ "$I_KNOW" == true ]]; then
warn "OVERRIDE — --i-know-what-im-doing active, per-run budget not applied"
cut -d"$(printf '\t')" -f3- "$TO_DELETE_FILE" > "$BUDGET_FILE"
_BUDGET_KEPT_COUNT=$TOTAL_REMOVED; _BUDGET_KEPT_BYTES=$TOTAL_DELETE_BYTES
_BUDGET_DEFERRED_COUNT=0; _BUDGET_DEFERRED_BYTES=0; _BUDGET_STUCK=""
else
apply_delete_budget "$TO_DELETE_FILE" "$BUDGET_FILE" "$LIDARR_MAX_DELETE_GB"
if [[ -n "$_BUDGET_STUCK" ]]; then
error "Single file exceeds the ${LIDARR_MAX_DELETE_GB}GB budget on its own — nothing removed this run"
error " $_BUDGET_STUCK"
error "Raise LIDARR_MAX_DELETE_GB or clear this one with --i-know-what-im-doing"
notify "Lidarr cleanup stalled on $(hostname) — one file exceeds the ${LIDARR_MAX_DELETE_GB}GB budget" \
"Lidarr Cleanup" "warning"
elif [[ "$_BUDGET_DEFERRED_COUNT" -gt 0 ]]; then
warn "Budget ${LIDARR_MAX_DELETE_GB}GB — removing $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED ($(format_bytes "$_BUDGET_KEPT_BYTES")), deferring $_BUDGET_DEFERRED_COUNT ($(format_bytes "$_BUDGET_DEFERRED_BYTES")) to the next run"
notify "Lidarr cleanup removed $(format_bytes "$_BUDGET_KEPT_BYTES") of $(format_bytes "$TOTAL_DELETE_BYTES") on $(hostname)$_BUDGET_DEFERRED_COUNT file(s) deferred" \
"Lidarr Cleanup" "normal"
fi
fi
# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────
# All safety layers passed — delete orphans and junk. Reuses TO_DELETE_FILE from the
@@ -521,7 +577,7 @@ if [[ "$DRY_RUN" == false ]]; then
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
rm -f "$filepath" 2>/dev/null || error "Failed to delete: $filepath"
done < "$TO_DELETE_FILE"
done < "$BUDGET_FILE"
info "Cleaning up empty folders..."
find "$LIDARR_MUSIC_ROOT" -mindepth 1 -type d -empty -delete 2>/dev/null
@@ -546,6 +602,10 @@ echo "$ICON_SHIELD Protected: $PROTECTED_COUNT files (cover art, metadata
echo "$ICON_TRASH Orphans: $ORPHAN_COUNT files ($ORPHAN_HUMAN)"
echo "$ICON_TRASH Junk: $JUNK_COUNT files ($JUNK_HUMAN)"
echo "$ICON_SKIP Recent skipped: $RECENT_COUNT files (under ${LIDARR_ORPHAN_AGE} days)"
[[ "${HELD_COUNT:-0}" -gt 0 ]] && \
echo "$ICON_SKIP Held (strikes): $HELD_COUNT files ($(format_bytes "$HELD_BYTES")) — under ${LIDARR_ORPHAN_STRIKE_LIMIT} consecutive runs"
[[ "${_BUDGET_DEFERRED_COUNT:-0}" -gt 0 ]] && \
echo "$ICON_SKIP Deferred: $_BUDGET_DEFERRED_COUNT files ($(format_bytes "$_BUDGET_DEFERRED_BYTES")) — over the ${LIDARR_MAX_DELETE_GB}GB run budget"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
@@ -554,8 +614,10 @@ if [[ "$DRY_RUN" == true ]]; then
elif [[ "$TOTAL_REMOVED" -eq 0 ]]; then
echo "$ICON_DONE Clean — nothing to remove"
else
warn "$ICON_DONE Removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)"
notify "Lidarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" "Lidarr Cleanup" "warning"
# What was actually removed, not what was classified. With strikes and a budget in force those
# differ, and reporting the classification as the outcome is the oldest bug shape here.
warn "$ICON_DONE Removed $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED eligible files ($(format_bytes "$_BUDGET_KEPT_BYTES"))"
notify "Lidarr cleanup on $(hostname) — removed $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED eligible files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" "Lidarr Cleanup" "warning"
# Notify Emby to clean missing files — removes ghost entries immediately
notify_emby_scan
fi
Executable → Regular
+134 -8
View File
@@ -450,9 +450,46 @@ NOW=$(date +%s)
# just delete them directly instead of re-walking and re-classifying every SCAN_ROOTS entry a
# second time (2026-07-17) — the size-threshold check below needs to know the total before
# deleting anything, not before knowing what to delete.
# Carries size and ctime alongside the path now, because the budget pass below has to order by
# age and stop at a byte ceiling — neither of which a bare path list can answer.
TO_DELETE_FILE="$TMP_DIR/to_delete_paths.txt"
> "$TO_DELETE_FILE"
# ── Orphan strikes ────────────────────────────────────────────────────────────────────────────
# A file must classify for deletion on RADARR_ORPHAN_STRIKE_LIMIT consecutive runs before it is
# actually removed. Gate 6 already refuses a run whose tracked count collapsed; this covers the
# partial failure underneath that threshold — one root folder failing to enumerate makes its
# movies look orphaned while the overall percentage still looks fine, and a transient fault will
# not reproduce on the next run.
#
# The file is REBUILT from this run's classifications rather than edited in place, which is what
# prunes it: anything that stopped being an orphan simply is not written again, so a file that
# Radarr re-adopts loses its strikes without needing a reset pass to find it.
#
# Keyed by host path, which is why this could not have worked before 2026-08-26 — wd_state_set
# built a regex from the key, and a release tag like [Bluray-1080p] holds the reversed range 1-0,
# so every write truncated the store to one line. See common.sh.
RADARR_ORPHAN_STRIKE_LIMIT="${RADARR_ORPHAN_STRIKE_LIMIT:-2}"
STRIKES_FILE="${RADARR_ORPHAN_STRIKES_FILE:-$DB_DIR/radarr_orphan_strikes.tsv}"
mkdir -p "$(dirname "$STRIKES_FILE")" 2>/dev/null || true
touch "$STRIKES_FILE" 2>/dev/null || true
STRIKES_NEW="$TMP_DIR/strikes_new.tsv"
> "$STRIKES_NEW"
HELD_COUNT=0
HELD_BYTES=0
# Records this run's strike for a file and says whether it has served enough of them.
# Returns 0 when the file may be deleted, 1 when it is still accruing.
orphan_strike_ok() {
local path="$1" prev strikes
prev=$(wd_state_get "$path" "$STRIKES_FILE"); prev="${prev//[^0-9]/}"
strikes=$(( ${prev:-0} + 1 ))
printf '%s:%s\n' "$path" "$strikes" >> "$STRIKES_NEW"
(( strikes >= RADARR_ORPHAN_STRIKE_LIMIT )) && return 0
warn " strike $strikes/$RADARR_ORPHAN_STRIKE_LIMIT — not removing yet: $path"
return 1
}
while read -r FILE_SIZE FILE_CTIME filepath; do
[[ -z "$filepath" ]] && continue
FILE_CTIME="${FILE_CTIME%%.*}"
@@ -487,12 +524,14 @@ while read -r FILE_SIZE FILE_CTIME filepath; do
warn "$ICON_TRASH ORPHAN: $filepath"
(( ORPHAN_COUNT++ ))
ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE ))
echo "$filepath" >> "$TO_DELETE_FILE"
if ! orphan_strike_ok "$filepath"; then (( HELD_COUNT++ )); HELD_BYTES=$(( HELD_BYTES + FILE_SIZE )); continue; fi
printf '%s\t%s\t%s\n' "$FILE_SIZE" "$FILE_CTIME" "$filepath" >> "$TO_DELETE_FILE"
else
log "JUNK: $filepath"
(( JUNK_COUNT++ ))
JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE ))
echo "$filepath" >> "$TO_DELETE_FILE"
if ! orphan_strike_ok "$filepath"; then (( HELD_COUNT++ )); HELD_BYTES=$(( HELD_BYTES + FILE_SIZE )); continue; fi
printf '%s\t%s\t%s\n' "$FILE_SIZE" "$FILE_CTIME" "$filepath" >> "$TO_DELETE_FILE"
fi
# -printf gets size + mtime directly from find's own stat() during the walk, instead of a
@@ -504,13 +543,94 @@ done < <(
done | sort -u
)
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT ))
# Eligible, not classified. A file still serving its strikes was counted as an orphan above — it
# is one — but it is not going to be deleted this run, so it must not appear in the denominator
# the budget reports against or the run claims to have skipped work it never queued.
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES - HELD_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT - HELD_COUNT ))
# Rebuilt, never edited: a path absent from this run is absent from the file, so a file Radarr
# re-adopts drops its strikes with no reset pass needed. Skipped on a dry run — a preview that
# advanced real strike counters would make the next real run delete a run early.
if [[ "$DRY_RUN" == false ]]; then
mv "$STRIKES_NEW" "$STRIKES_FILE" 2>/dev/null || warn "Could not update $STRIKES_FILE"
fi
# ==============================================================================================
# ━━━ Safety Layer 7 — Deletion Size Threshold ━━━
# ==============================================================================================
check_delete_size_threshold "$TOTAL_DELETE_BYTES" "$RADARR_MAX_DELETE_GB" "Radarr Cleanup"
# The ceiling is a per-run budget, not a veto. It still means what it always meant — no single run
# removes more than RADARR_MAX_DELETE_GB — but a backlog larger than the ceiling now drains over
# consecutive nights instead of failing the orchestrator forever on a queue it cannot clear.
# ── AI note (AI_ASSIST_CLEANUP) ───────────────────────────────────────────────────────────────
# Describes the shape of what was classified. It decides nothing: the eligible set, the budget and
# the strikes are all settled above and none of them read this. Switch AI_ASSIST_CLEANUP off and
# the run removes exactly the same files — the log just loses a paragraph.
#
# ctime clustering is the signal worth surfacing. A normal upgrade cycle dribbles in over weeks; a
# lump sharing one narrow ctime window with mtimes spread across months is a bulk write-back, which
# is what a partnership merge against a partner holding older copies produces. That distinction
# took a person an evening on 2026-08-26 and is the whole reason this note exists.
if [[ "$ORPHAN_COUNT" -gt 0 ]] && [[ -s "$TO_DELETE_FILE" ]]; then
_ai_ev=$(awk -F'\t' '
{ n++; bytes += $1
c = int($2)
if (cmin == 0 || c < cmin) cmin = c
if (c > cmax) cmax = c
bucket[int(c / 21600)]++ }
END {
for (b in bucket) if (bucket[b] > top) { top = bucket[b] }
printf "files=%d bytes_gb=%.1f ctime_span_hours=%.1f largest_6h_ctime_bucket=%d\n",
n, bytes/1073741824, (cmax-cmin)/3600, top
}' "$TO_DELETE_FILE")
_ai_mt=$(cut -d"$(printf '\t')" -f3 "$TO_DELETE_FILE" | head -8 \
| while IFS= read -r p; do [[ -f "$p" ]] && \
printf '%s %s\n' "$(stat -c %y "$p" 2>/dev/null | cut -c1-7)" "$(basename "$p")"; done)
_ai_note=$(ai_assist_note AI_ASSIST_CLEANUP "You are looking at files an automated media-library cleanup has classified for deletion on an Unraid server. They are files on disk that the Radarr database no longer references.
EVIDENCE
$_ai_ev
sample (modification month, then path):
$_ai_mt
A normal quality-upgrade cycle produces orphans whose ctimes are spread out over weeks, because each upgrade happens on its own day. A bulk event - a sync or restore writing files back onto this host - produces orphans sharing one narrow ctime window while their modification times stay spread across months, because the copy preserves modification time but resets ctime.
In no more than three sentences, say which of those two this looks like and name the numbers above that support it. Do not recommend an action. Do not speculate beyond the evidence given.") || _ai_note=""
if [[ -n "$_ai_note" ]]; then
echo ""
echo "━━━ $ICON_GEAR AI note on this classification ━━━"
printf '%s\n' "$_ai_note"
fi
unset _ai_ev _ai_mt
fi
BUDGET_FILE="$TMP_DIR/to_delete_budgeted.txt"
if [[ "$I_KNOW" == true ]]; then
warn "OVERRIDE — --i-know-what-im-doing active, per-run budget not applied"
cut -d"$(printf '\t')" -f3- "$TO_DELETE_FILE" > "$BUDGET_FILE"
_BUDGET_KEPT_COUNT=$TOTAL_REMOVED; _BUDGET_KEPT_BYTES=$TOTAL_DELETE_BYTES
_BUDGET_DEFERRED_COUNT=0; _BUDGET_DEFERRED_BYTES=0; _BUDGET_STUCK=""
else
apply_delete_budget "$TO_DELETE_FILE" "$BUDGET_FILE" "$RADARR_MAX_DELETE_GB"
if [[ -n "$_BUDGET_STUCK" ]]; then
# One file larger than the whole budget can never fit, so it would be re-found and
# re-deferred every night. Name it rather than loop on it silently.
error "Single file exceeds the ${RADARR_MAX_DELETE_GB}GB budget on its own — nothing removed this run"
error " $_BUDGET_STUCK"
error "Raise RADARR_MAX_DELETE_GB or clear this one with --i-know-what-im-doing"
notify "Radarr cleanup stalled on $(hostname) — one file exceeds the ${RADARR_MAX_DELETE_GB}GB budget" \
"Radarr Cleanup" "warning"
elif [[ "$_BUDGET_DEFERRED_COUNT" -gt 0 ]]; then
warn "Budget ${RADARR_MAX_DELETE_GB}GB — removing $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED ($(format_bytes "$_BUDGET_KEPT_BYTES")), deferring $_BUDGET_DEFERRED_COUNT ($(format_bytes "$_BUDGET_DEFERRED_BYTES")) to the next run"
warn "Oldest first — the deferred files are the newest and are re-evaluated tomorrow"
notify "Radarr cleanup removed $(format_bytes "$_BUDGET_KEPT_BYTES") of $(format_bytes "$TOTAL_DELETE_BYTES") on $(hostname)$_BUDGET_DEFERRED_COUNT file(s) deferred to the next run" \
"Radarr Cleanup" "normal"
fi
fi
# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────
# Reuses TO_DELETE_FILE from the classification pass above instead of re-walking and
@@ -519,7 +639,7 @@ if [[ "$DRY_RUN" == false ]]; then
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
rm -f "$filepath" 2>/dev/null || error "Failed to delete: $filepath"
done < "$TO_DELETE_FILE"
done < "$BUDGET_FILE"
info "Cleaning up empty folders..."
for host_path in "${SCAN_ROOTS[@]}"; do
@@ -545,6 +665,10 @@ echo "$ICON_SHIELD Protected: $PROTECTED_COUNT files (artwork, subtitles,
echo "$ICON_TRASH Orphans: $ORPHAN_COUNT files ($ORPHAN_HUMAN)"
echo "$ICON_TRASH Junk: $JUNK_COUNT files ($JUNK_HUMAN)"
echo "$ICON_SKIP Recent skipped: $RECENT_COUNT files (under ${RADARR_ORPHAN_AGE} days)"
[[ "${HELD_COUNT:-0}" -gt 0 ]] && \
echo "$ICON_SKIP Held (strikes): $HELD_COUNT files ($(format_bytes "$HELD_BYTES")) — under ${RADARR_ORPHAN_STRIKE_LIMIT} consecutive runs"
[[ "${_BUDGET_DEFERRED_COUNT:-0}" -gt 0 ]] && \
echo "$ICON_SKIP Deferred: $_BUDGET_DEFERRED_COUNT files ($(format_bytes "$_BUDGET_DEFERRED_BYTES")) — over the ${RADARR_MAX_DELETE_GB}GB run budget"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
@@ -553,8 +677,10 @@ if [[ "$DRY_RUN" == true ]]; then
elif [[ "$TOTAL_REMOVED" -eq 0 ]]; then
echo "$ICON_DONE Clean — nothing to remove"
else
warn "$ICON_DONE Removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)"
notify "Radarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" \
# What was actually removed, not what was classified. With a budget in force those differ, and
# reporting the classification as the outcome is the oldest bug shape in this codebase.
warn "$ICON_DONE Removed $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED classified files ($(format_bytes "$_BUDGET_KEPT_BYTES"))"
notify "Radarr cleanup on $(hostname) — removed $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED classified files ($(format_bytes "$_BUDGET_KEPT_BYTES"))$([[ "${_BUDGET_DEFERRED_COUNT:-0}" -gt 0 ]] && echo ", $_BUDGET_DEFERRED_COUNT deferred")" \
"Radarr Cleanup" "warning"
# Notify Emby to clean missing files — removes ghost entries immediately
notify_emby_scan
Executable → Regular
+70 -8
View File
@@ -440,6 +440,30 @@ NOW=$(date +%s)
TO_DELETE_FILE="$TMP_DIR/to_delete_paths.txt"
> "$TO_DELETE_FILE"
# ── Orphan strikes ────────────────────────────────────────────────────────────────────────────
# Same contract as radarr_cleanup.sh: a file must classify for deletion on
# SONARR_ORPHAN_STRIKE_LIMIT consecutive runs before it is removed. Covers the partial
# classification failure that is too small to trip the tracked-count floor above. The file is
# rebuilt from each run rather than edited, which is what prunes it.
SONARR_ORPHAN_STRIKE_LIMIT="${SONARR_ORPHAN_STRIKE_LIMIT:-2}"
STRIKES_FILE="${SONARR_ORPHAN_STRIKES_FILE:-$DB_DIR/sonarr_orphan_strikes.tsv}"
mkdir -p "$(dirname "$STRIKES_FILE")" 2>/dev/null || true
touch "$STRIKES_FILE" 2>/dev/null || true
STRIKES_NEW="$TMP_DIR/strikes_new.tsv"
> "$STRIKES_NEW"
HELD_COUNT=0
HELD_BYTES=0
orphan_strike_ok() {
local path="$1" prev strikes
prev=$(wd_state_get "$path" "$STRIKES_FILE"); prev="${prev//[^0-9]/}"
strikes=$(( ${prev:-0} + 1 ))
printf '%s:%s\n' "$path" "$strikes" >> "$STRIKES_NEW"
(( strikes >= SONARR_ORPHAN_STRIKE_LIMIT )) && return 0
warn " strike $strikes/$SONARR_ORPHAN_STRIKE_LIMIT — not removing yet: $path"
return 1
}
while read -r FILE_SIZE FILE_CTIME filepath; do
[[ -z "$filepath" ]] && continue
FILE_CTIME="${FILE_CTIME%%.*}"
@@ -474,12 +498,14 @@ while read -r FILE_SIZE FILE_CTIME filepath; do
warn "$ICON_TRASH ORPHAN: $filepath"
(( ORPHAN_COUNT++ ))
ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE ))
echo "$filepath" >> "$TO_DELETE_FILE"
if ! orphan_strike_ok "$filepath"; then (( HELD_COUNT++ )); HELD_BYTES=$(( HELD_BYTES + FILE_SIZE )); continue; fi
printf '%s\t%s\t%s\n' "$FILE_SIZE" "$FILE_CTIME" "$filepath" >> "$TO_DELETE_FILE"
else
log "JUNK: $filepath"
(( JUNK_COUNT++ ))
JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE ))
echo "$filepath" >> "$TO_DELETE_FILE"
if ! orphan_strike_ok "$filepath"; then (( HELD_COUNT++ )); HELD_BYTES=$(( HELD_BYTES + FILE_SIZE )); continue; fi
printf '%s\t%s\t%s\n' "$FILE_SIZE" "$FILE_CTIME" "$filepath" >> "$TO_DELETE_FILE"
fi
# -printf gets size + mtime directly from find's own stat() during the walk, instead of a
@@ -491,13 +517,43 @@ done < <(
done | sort -u
)
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT ))
# Eligible, not classified: a file still serving its strikes is an orphan but is not queued this
# run, so it must not appear in the denominator the budget reports against.
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES - HELD_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT - HELD_COUNT ))
# Rebuilt, never edited. Skipped on a dry run: a preview that advanced real counters would make
# the next real run delete a run early.
if [[ "$DRY_RUN" == false ]]; then
mv "$STRIKES_NEW" "$STRIKES_FILE" 2>/dev/null || warn "Could not update $STRIKES_FILE"
fi
# ==============================================================================================
# ━━━ Safety Layer 7 — Deletion Size Threshold ━━━
# ==============================================================================================
check_delete_size_threshold "$TOTAL_DELETE_BYTES" "$SONARR_MAX_DELETE_GB" "Sonarr Cleanup"
# A per-run budget, not a veto — see apply_delete_budget() in common.sh. The ceiling still caps
# any single run; it just no longer deadlocks on a backlog larger than itself.
BUDGET_FILE="$TMP_DIR/to_delete_budgeted.txt"
if [[ "$I_KNOW" == true ]]; then
warn "OVERRIDE — --i-know-what-im-doing active, per-run budget not applied"
cut -d"$(printf '\t')" -f3- "$TO_DELETE_FILE" > "$BUDGET_FILE"
_BUDGET_KEPT_COUNT=$TOTAL_REMOVED; _BUDGET_KEPT_BYTES=$TOTAL_DELETE_BYTES
_BUDGET_DEFERRED_COUNT=0; _BUDGET_DEFERRED_BYTES=0; _BUDGET_STUCK=""
else
apply_delete_budget "$TO_DELETE_FILE" "$BUDGET_FILE" "$SONARR_MAX_DELETE_GB"
if [[ -n "$_BUDGET_STUCK" ]]; then
error "Single file exceeds the ${SONARR_MAX_DELETE_GB}GB budget on its own — nothing removed this run"
error " $_BUDGET_STUCK"
error "Raise SONARR_MAX_DELETE_GB or clear this one with --i-know-what-im-doing"
notify "Sonarr cleanup stalled on $(hostname) — one file exceeds the ${SONARR_MAX_DELETE_GB}GB budget" \
"Sonarr Cleanup" "warning"
elif [[ "$_BUDGET_DEFERRED_COUNT" -gt 0 ]]; then
warn "Budget ${SONARR_MAX_DELETE_GB}GB — removing $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED ($(format_bytes "$_BUDGET_KEPT_BYTES")), deferring $_BUDGET_DEFERRED_COUNT ($(format_bytes "$_BUDGET_DEFERRED_BYTES")) to the next run"
notify "Sonarr cleanup removed $(format_bytes "$_BUDGET_KEPT_BYTES") of $(format_bytes "$TOTAL_DELETE_BYTES") on $(hostname)$_BUDGET_DEFERRED_COUNT file(s) deferred" \
"Sonarr Cleanup" "normal"
fi
fi
# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────
# Reuses TO_DELETE_FILE from the classification pass above instead of re-walking and
@@ -506,7 +562,7 @@ if [[ "$DRY_RUN" == false ]]; then
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
rm -f "$filepath" 2>/dev/null || error "Failed to delete: $filepath"
done < "$TO_DELETE_FILE"
done < "$BUDGET_FILE"
info "Cleaning up empty folders..."
for host_path in "${SCAN_ROOTS[@]}"; do
@@ -532,6 +588,10 @@ echo "$ICON_SHIELD Protected: $PROTECTED_COUNT files (artwork, subtitles,
echo "$ICON_TRASH Orphans: $ORPHAN_COUNT files ($ORPHAN_HUMAN)"
echo "$ICON_TRASH Junk: $JUNK_COUNT files ($JUNK_HUMAN)"
echo "$ICON_SKIP Recent skipped: $RECENT_COUNT files (under ${SONARR_ORPHAN_AGE} days)"
[[ "${HELD_COUNT:-0}" -gt 0 ]] && \
echo "$ICON_SKIP Held (strikes): $HELD_COUNT files ($(format_bytes "$HELD_BYTES")) — under ${SONARR_ORPHAN_STRIKE_LIMIT} consecutive runs"
[[ "${_BUDGET_DEFERRED_COUNT:-0}" -gt 0 ]] && \
echo "$ICON_SKIP Deferred: $_BUDGET_DEFERRED_COUNT files ($(format_bytes "$_BUDGET_DEFERRED_BYTES")) — over the ${SONARR_MAX_DELETE_GB}GB run budget"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
@@ -540,8 +600,10 @@ if [[ "$DRY_RUN" == true ]]; then
elif [[ "$TOTAL_REMOVED" -eq 0 ]]; then
echo "$ICON_DONE Clean — nothing to remove"
else
warn "$ICON_DONE Removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)"
notify "Sonarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" \
# What was actually removed, not what was classified. With strikes and a budget in force those
# differ, and reporting the classification as the outcome is the oldest bug shape here.
warn "$ICON_DONE Removed $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED eligible files ($(format_bytes "$_BUDGET_KEPT_BYTES"))"
notify "Sonarr cleanup on $(hostname) — removed $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED eligible files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" \
"Sonarr Cleanup" "warning"
# Notify Emby to clean missing files — removes ghost entries immediately
notify_emby_scan
+27 -1
View File
@@ -1268,6 +1268,8 @@
LIDARR_MIN_TRACKED_PCT=80 # abort if tracked count drops below this % of last run
# protects against API returning partial data on a bad day
LIDARR_TRACKED_COUNT_FILE="${DB_DIR}/lidarr_tracked.count"
LIDARR_ORPHAN_STRIKES_FILE="${DB_DIR}/lidarr_orphan_strikes.tsv" # consecutive-classification counts, keyed by host path
LIDARR_ORPHAN_STRIKE_LIMIT=2 # consecutive runs a file must classify before it is removed
LIDARR_IMPORT_SCAN_TIMEOUT=600 # seconds to wait for pre-flight import scan
# Lidarr tracked-data cache — shared by lidarr_cleanup.sh, lidarr_duplicate_artist_cleanup.sh,
@@ -1343,6 +1345,8 @@
SONARR_MIN_TRACKED_PCT=80 # abort if tracked count drops below this % of last run
# protects against API returning partial data on a bad day
SONARR_TRACKED_COUNT_FILE="${DB_DIR}/sonarr_tracked.count"
SONARR_ORPHAN_STRIKES_FILE="${DB_DIR}/sonarr_orphan_strikes.tsv" # consecutive-classification counts, keyed by host path
SONARR_ORPHAN_STRIKE_LIMIT=2 # consecutive runs a file must classify before it is removed
SONARR_IMPORT_SCAN_TIMEOUT=600 # seconds to wait for pre-flight import scan
SONARR_MOVE_POLL_TIMEOUT=3600 # seconds to wait for a single async MoveSeries command to
# reach "completed" — generous because a large series can sit
@@ -1355,6 +1359,15 @@
# against a one-off ffprobe hiccup (mid-write file, NFS blip)
# triggering an unnecessary delete. Resets to 0 the moment a
# file probes clean again.
CORRUPTION_SCAN_MAX_CORRUPT_PCT=10 # abort the run, committing nothing, if this share of
# newly-scanned files reads as corrupt. A healthy library sits
# near zero; a high rate means the detector is wrong, not the
# library. Only counts ffprobe exit != 0.
CORRUPTION_SCAN_MAX_CONSECUTIVE=15 # abort after this many files in a row fail to probe
# cleanly — catches the ffprobe container dying mid-scan,
# which the pre-flight health check cannot see.
CORRUPTION_SCAN_GUARD_MIN_SCANNED=20 # below this many newly-scanned files the percentage
# ceiling is not applied — too small a sample to judge.
SONARR_EXTENSIONS=("mkv" "mp4" "avi" "m4v" "ts" "wmv" "mov")
SONARR_PROTECTED_PATTERNS=(
# Subtitles
@@ -1381,6 +1394,12 @@
RADARR_MIN_TRACKED_PCT=80 # abort if tracked count drops below this % of last run
# protects against API returning partial data on a bad day
RADARR_TRACKED_COUNT_FILE="${DB_DIR}/radarr_tracked.count"
RADARR_ORPHAN_STRIKES_FILE="${DB_DIR}/radarr_orphan_strikes.tsv" # consecutive-classification counts, keyed by host path
RADARR_ORPHAN_STRIKE_LIMIT=2 # consecutive runs a file must classify for deletion before it is
# removed. Gate 6 already catches an API returning far too few
# tracked files; this catches the partial failure too small to trip
# that percentage — one root folder failing to enumerate makes its
# movies look orphaned, and a transient one will not repeat.
RADARR_IMPORT_SCAN_TIMEOUT=600 # seconds to wait for pre-flight import scan
RADARR_MOVE_POLL_TIMEOUT=3600 # seconds to wait for a single async MoveMovie command to
# reach "completed" — mirrors SONARR_MOVE_POLL_TIMEOUT
@@ -1647,6 +1666,10 @@
# HOST1_BACKUP_VERIFY_SHARES / HOST2_BACKUP_VERIFY_SHARES
BACKUP_VERIFY_SAMPLE=10 # random files to check per share
BACKUP_VERIFY_MIN_SIZE=1M # minimum file size to include in sample
BACKUP_VERIFY_MD5_TIMEOUT_MAX=600 # ceiling for one remote checksum. The per-file budget
# scales with size (~50MB/s); this caps it. A fixed
# connect-sized timeout killed multi-GB checksums and
# the empty result was then reported as MISSING.
# ━━━ SMART Health ━━━
# Monitors drive SMART attributes — discovers all drives via /dev/sd* and /dev/nvme*.
@@ -2025,7 +2048,10 @@
AI_ASSIST_REPORTS=false # tier 1 — digest / coffee report narration
AI_ASSIST_WATCHDOG=false # tier 2 — file a finding when a watchdog counter passes its limit (needs AI_REPAIR_ENABLED)
AI_ASSIST_DISCOVERY=false # tier 2 — discovery / classification judgement calls
AI_ASSIST_CLEANUP=false # tier 2 — orphan and stuck-import triage
AI_ASSIST_CLEANUP=false # tier 2 — orphan and stuck-import triage. Describes the shape of a
# classification in the log; decides nothing. Off = identical deletions.
AI_ASSIST_TIMEOUT=45 # seconds any single assist may take. An assist that can stall a
# nightly cleanup is not an assist — it is silently skipped past this.
AI_ASSIST_ONBOARD=false # tier 3 — onboarding / settings assistance
# ━━━ AI Repair ━━━
+55
View File
@@ -28,6 +28,42 @@
# pointing at a directory the data is not in is worse than not having started.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Two halves, in order: move the files, then rewrite the conf keys that point at them. Doing it
# the other way round would leave every path variable naming a location nothing had reached yet,
# and any script that ran in between would create the old layout again underneath the new one.
#
# Idempotent. A path already under DATA_DIR is left alone, so a re-run after a partial migration
# finishes the job rather than moving things twice or failing on what is already done.
#
# One-time by intent, not by a marker file. There is no "already migrated" flag — the check is
# whether each individual path is already where it belongs, which is also what makes an
# interrupted run safe to repeat.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Existing keys are rewritten, which is why conf_upgrade cannot do this.
# conf_upgrade adds keys the template has and the installation does not, and never rewrites a
# value the operator already holds — correct for it, and exactly why it is the wrong tool here.
# STATE_DIR, BANDWIDTH_LOG, AI_INDEX_DB and two dozen more are existing keys whose values must
# change, or they would go on naming the old layout forever while the new directory variables
# sat beside them unused.
#
# Move, never copy-and-hope.
# The data being relocated is the only copy — statistics, histories, the AI index, arr caches.
# Everything is moved and the source is gone afterwards, so there is no second location that
# might still be written to by something that missed the change.
#
# The conf rewrite is the last thing, and the riskiest thing.
# Until it happens the installation still works from the old layout. That ordering means an
# abort partway through leaves a system that runs, rather than one whose paths point at
# nothing.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
@@ -49,6 +85,25 @@
# check costs nothing and the failure is silent otherwise.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# This script reads conf to find the old locations and rewrites conf to record the new ones. It
# is the one script here whose purpose is to change these values rather than obey them.
#
# Read to locate what moves
# STATE_DIR, BANDWIDTH_LOG, AI_INDEX_DB, AI_MEMORY_FILE, AI_TOKEN_DB, ARR_CLEANUP_STATS,
# ARR_SYNC_BLOCKLIST, CORRUPTION_SCAN_STATE_FILE, LIDARR_CACHE_FILE, ZFS_REPORT_LOG and the
# rest of the per-script path keys — roughly two dozen in total.
#
# Written as the new roots
# DATA_DIR and the directories beneath it: DB_DIR, STATE_DIR, AI_DATA_DIR,
# CACHE_BACKUP_DIR, ARR_CACHE_BACKUP_DIR, CONF_CACHE_BACKUP_DIR, LOG_ARCHIVE_DIR.
#
# Every rewritten value is expressed as ${DB_DIR}/… rather than an absolute path, so a later
# storage-mode migration moves them again by changing one variable.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
+37 -1
View File
@@ -42,7 +42,43 @@
# my-Foo.xml routinely holds a container called something else. Matching on the filename
# silently pushes the wrong template, or nothing at all.
#
# USAGE
# OPERATIONAL SAFEGUARDS
# Only in NORMAL state. FALLBACK_STATE_FILE is read before anything is pushed or removed, and
# any other state refuses the action. A push during a live failover would deploy a second copy
# of a container the partner is currently running on our behalf; a remove would delete the one
# doing the covering.
#
# --status is exempt from that gate, because it only reports. Refusing to answer "what is
# deployed over there" during a failover would withhold the information precisely when it is
# most wanted.
#
# Every deploy is verified stopped, and a container that will not stay stopped is an error
# rather than a warning — see DESIGN PRINCIPLES. A second live instance against the same data
# is the failure this whole script exists inside.
#
# Push and remove are explicit modes with no default. Running the script with no flag does
# nothing; neither action can be reached by accident, and neither is a side effect of editing
# the tier list.
#
# --dry-run works in every mode and touches nothing on either host — no container is built,
# started, stopped or removed, and no template is written or deleted.
#
# Remove deletes the container's appdata on the partner as well. That is deliberate and is the
# most destructive thing here; the NORMAL-state gate above is what keeps it away from a
# partner that is mid-handback.
#
# CONFIGURATION
# master.conf
# FALLBACK_<HOST>_TIER1..N the covered container names — what --push deploys and --status
# reports on. This script reads that list; it never edits it.
#
# host*.conf
# FALLBACK_STATE_FILE overrides where fallback.sh's state is read from. Defaults to
# STATE_DIR/fallback_state.db. A missing file reads as NORMAL,
# which is the correct default on a host where fallback has never
# run.
#
# RUNTIME MODES
# coverage_deploy.sh --push deploy every covered container onto the partner (stopped)
# coverage_deploy.sh --remove stop, remove, and delete the pushed template on the partner
# coverage_deploy.sh --status report, per covered container, whether it exists there
Executable → Regular
+46 -10
View File
@@ -122,6 +122,7 @@ source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
SSH_TIMEOUT=15
BACKUP_VERIFY_MD5_TIMEOUT_MAX="${BACKUP_VERIFY_MD5_TIMEOUT_MAX:-600}"
# ==============================================================================================
# ━━━ Setup ━━━
@@ -228,6 +229,7 @@ TOTAL_CHECKED=0
TOTAL_MATCH=0
TOTAL_MISMATCH=0
TOTAL_MISSING=0
TOTAL_UNVERIFIED=0
SHARES_WITH_ISSUES=()
for share in "${VERIFY_SHARES[@]}"; do
@@ -265,6 +267,7 @@ for share in "${VERIFY_SHARES[@]}"; do
SHARE_MATCH=0
SHARE_MISMATCH=0
SHARE_MISSING=0
SHARE_UNVERIFIED=0
for local_file in "${SAMPLE_FILES[@]}"; do
[[ -z "$local_file" ]] && continue
@@ -276,19 +279,49 @@ for share in "${VERIFY_SHARES[@]}"; do
continue
fi
# Remote checksum via SSH — timeout protected
remote_md5=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
# The path is interpolated into a remote shell command, so it must be escaped for
# reuse as one word. A bare '$local_file' inside single quotes breaks on the first
# apostrophe — "Frieren - Beyond Journey's End" ended the quote early, md5sum fell
# back to reading stdin, and the empty-input hash d41d8cd9... was reported as a
# MISMATCH against a file that is byte-identical on the remote.
printf -v remote_q '%q' "$local_file"
# Existence and content are separate questions. Asking them together means a slow
# checksum is indistinguishable from an absent file.
remote_exists=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
-o StrictHostKeyChecking=no \
root@"$REMOTE_SERVER" \
"md5sum '$local_file' 2>/dev/null | awk '{print \$1}'" 2>/dev/null)
"test -f $remote_q && echo yes" 2>/dev/null </dev/null)
(( TOTAL_CHECKED++ ))
if [[ -z "$remote_md5" ]]; then
if [[ "$remote_exists" != "yes" ]]; then
warn "$ICON_ERROR MISSING: $(basename "$local_file")"
(( SHARE_MISSING++ ))
(( TOTAL_MISSING++ ))
continue
fi
# md5sum of a multi-GB file cannot finish inside a connect-sized timeout. Budget by
# size — a 5.9GB file needs ~30s and was being killed at 15s, then counted MISSING
# even though it was present and correct.
local_size=$(stat -c%s "$local_file" 2>/dev/null || echo 0)
md5_timeout=$(( local_size / 52428800 + SSH_TIMEOUT ))
(( md5_timeout > BACKUP_VERIFY_MD5_TIMEOUT_MAX )) && md5_timeout=$BACKUP_VERIFY_MD5_TIMEOUT_MAX
remote_md5=$(timeout "$md5_timeout" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
-o StrictHostKeyChecking=no \
root@"$REMOTE_SERVER" \
"md5sum $remote_q 2>/dev/null | awk '{print \$1}'" 2>/dev/null </dev/null)
if [[ -z "$remote_md5" ]]; then
# Present but unreadable within budget. Reporting this as a mismatch or a miss
# would be a claim the run did not earn.
warn "$ICON_WARN UNVERIFIED (checksum timed out after ${md5_timeout}s): $(basename "$local_file")"
(( SHARE_UNVERIFIED++ ))
(( TOTAL_UNVERIFIED++ ))
elif [[ "$local_md5" == "$remote_md5" ]]; then
log "MATCH: $(basename "$local_file")"
(( SHARE_MATCH++ ))
@@ -303,8 +336,8 @@ for share in "${VERIFY_SHARES[@]}"; do
done
# Per-share result — only visible if issues found
if [[ "$SHARE_MISMATCH" -gt 0 || "$SHARE_MISSING" -gt 0 ]]; then
warn "$SHARE_NAME — match: $SHARE_MATCH missing: $SHARE_MISSING mismatch: $SHARE_MISMATCH"
if [[ "$SHARE_MISMATCH" -gt 0 || "$SHARE_MISSING" -gt 0 || "$SHARE_UNVERIFIED" -gt 0 ]]; then
warn "$SHARE_NAME — match: $SHARE_MATCH missing: $SHARE_MISSING mismatch: $SHARE_MISMATCH unverified: $SHARE_UNVERIFIED"
SHARES_WITH_ISSUES+=("$SHARE_NAME")
else
echo "$SHARE_NAME — all $SHARE_MATCH files match ✅"
@@ -325,10 +358,11 @@ echo "$ICON_VERIFY Checked: $TOTAL_CHECKED files"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$TOTAL_MISMATCH" -gt 0 || "$TOTAL_MISSING" -gt 0 ]]; then
echo "$ICON_SUCCESS Match: $TOTAL_MATCH"
warn "Missing: $TOTAL_MISSING"
[[ "$TOTAL_MISMATCH" -gt 0 ]] && echo "$ICON_ERROR Mismatch: $TOTAL_MISMATCH"
if [[ "$TOTAL_MISMATCH" -gt 0 || "$TOTAL_MISSING" -gt 0 || "$TOTAL_UNVERIFIED" -gt 0 ]]; then
echo "$ICON_SUCCESS Match: $TOTAL_MATCH"
warn "Missing: $TOTAL_MISSING"
[[ "$TOTAL_MISMATCH" -gt 0 ]] && echo "$ICON_ERROR Mismatch: $TOTAL_MISMATCH"
[[ "$TOTAL_UNVERIFIED" -gt 0 ]] && warn "Unverified: $TOTAL_UNVERIFIED (present, checksum timed out)"
fi
if [[ "$DRY_RUN" == true ]]; then
@@ -337,6 +371,8 @@ elif [[ "$TOTAL_MISMATCH" -gt 0 || "$TOTAL_MISSING" -gt 0 ]]; then
echo "$ICON_ERROR Status: ISSUES FOUND — ${#SHARES_WITH_ISSUES[@]} share(s) need attention: ${SHARES_WITH_ISSUES[*]}"
notify "Backup verify FAILED on $(hostname)$REMOTE_SERVER_NAME — mismatches: $TOTAL_MISMATCH missing: $TOTAL_MISSING — shares: ${SHARES_WITH_ISSUES[*]}" \
"Backup Verify" "warning"
elif [[ "$TOTAL_UNVERIFIED" -gt 0 ]]; then
warn "Status: $TOTAL_MATCH verified, $TOTAL_UNVERIFIED could not be checksummed in time — NOT a clean run"
else
echo "$ICON_DONE Status: all $TOTAL_CHECKED files match across ${#VERIFY_SHARES[@]} shares ✅"
fi
+17 -17
View File
@@ -18,6 +18,22 @@
# WebGUI slowdowns or timeouts under load.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Each run snapshots:
# inotify: instances in use vs INOTIFY_MAX_INSTANCES kernel limit.
# Top 5 consumers by instance count. Warns above INOTIFY_WARN_PCT.
# php-fpm: active workers vs PHP_MAX_CHILDREN limit.
# Warns above PHP_FPM_WARN_PCT.
#
# Log line format (one per run, trimmed to TUNING_LOG_RETENTION days):
# DATE|TIME|INOTIFY_USED|INOTIFY_LIMIT|INOTIFY_PCT|INOTIFY_WARN|
# PHPFPM_ACTIVE|PHPFPM_MAX|PHPFPM_PCT|PHPFPM_WARN
# INOTIFY_WARN and PHPFPM_WARN are 1/0 flags. weekly_health_digest.sh counts
# warnings over the week to show trend severity.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
@@ -36,22 +52,6 @@
# signal — routine snapshots below the threshold produce nothing.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Each run snapshots:
# inotify: instances in use vs INOTIFY_MAX_INSTANCES kernel limit.
# Top 5 consumers by instance count. Warns above INOTIFY_WARN_PCT.
# php-fpm: active workers vs PHP_MAX_CHILDREN limit.
# Warns above PHP_FPM_WARN_PCT.
#
# Log line format (one per run, trimmed to TUNING_LOG_RETENTION days):
# DATE|TIME|INOTIFY_USED|INOTIFY_LIMIT|INOTIFY_PCT|INOTIFY_WARN|
# PHPFPM_ACTIVE|PHPFPM_MAX|PHPFPM_PCT|PHPFPM_WARN
# INOTIFY_WARN and PHPFPM_WARN are 1/0 flags. weekly_health_digest.sh counts
# warnings over the week to show trend severity.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
@@ -259,4 +259,4 @@ echo "${DATE}|${TIME}|${INOTIFY_USED}|${INOTIFY_LIMIT}|${INOTIFY_PCT}|${INOTIFY_
>> "$TUNING_MONITOR_LOG"
echo "Snapshot written: inotify ${INOTIFY_PCT}% php-fpm ${PHPFPM_PCT}%"
log "Entry: ${DATE}|${TIME}|${INOTIFY_USED}/${INOTIFY_LIMIT}(${INOTIFY_PCT}%,warn=${INOTIFY_WARN})|${PHPFPM_ACTIVE}/${PHPFPM_MAX}(${PHPFPM_PCT}%,warn=${PHPFPM_WARN})"
log "Entry: ${DATE}|${TIME}|${INOTIFY_USED}/${INOTIFY_LIMIT}(${INOTIFY_PCT}%,warn=${INOTIFY_WARN})|${PHPFPM_ACTIVE}/${PHPFPM_MAX}(${PHPFPM_PCT}%,warn=${PHPFPM_WARN})"
+36
View File
@@ -20,6 +20,42 @@
# decision to notify is the exit code rather than this script parsing the text it just printed.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Silence is the normal output.
# A report that always says something is a report nobody reads. A perfect week prints nothing
# and notifies nothing, so anything that does appear in the Sunday report is worth the glance.
#
# The exit code is the decision, not the text.
# uptime_probe.php --report exits 1 when it has something to say and 0 when it does not. This
# script never parses the output it just printed to work out whether to notify — a report whose
# wording changed would otherwise silently stop notifying.
#
# It reads; it never probes.
# The measurements are already taken, once a minute, by Tools/uptime_probe.sh. Re-probing at
# report time would describe Sunday morning rather than the week being reported on.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Read-only. Reads the stored history and prints; records nothing, and cannot alter the data it
# is reporting on.
#
# UPTIME_PROBE_ENABLED gates the whole run — with the probe off there is no history worth
# reporting, and this says nothing rather than reporting an empty week as a perfect one.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# uptime_report.sh the weekly read. Silent when every domain was 100%.
#
# Called from COFFEE_REPORT_SCRIPTS; takes no arguments and has no other mode. For live figures
# or a per-domain table, use Tools/uptime_probe.sh --status.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
Executable → Regular
+19 -19
View File
@@ -14,24 +14,6 @@
# into a single digest. Reads only — writes nothing, changes nothing.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Aggregator, Not Generator
# This script reads state files that other scripts maintain. It never produces
# health data itself — it only presents what is already there. Each source
# script remains responsible for its own state; this script is the envelope.
#
# Profile-Driven Notification
# The cron schedule never changes. The DIGEST_PROFILE in master.conf controls
# when notifications actually send — switching from daily noise to weekly
# summaries is a one-line conf change, not a cron edit.
#
# Read-Only, No Side Effects
# Writes nothing, changes nothing, triggers nothing. Safe to run at any time
# for a health snapshot without affecting any running service or state file.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
@@ -58,6 +40,24 @@
# RAMDISK_PATH / TRANSCODE_LINK — current transcode location and usage
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Aggregator, Not Generator
# This script reads state files that other scripts maintain. It never produces
# health data itself — it only presents what is already there. Each source
# script remains responsible for its own state; this script is the envelope.
#
# Profile-Driven Notification
# The cron schedule never changes. The DIGEST_PROFILE in master.conf controls
# when notifications actually send — switching from daily noise to weekly
# summaries is a one-line conf change, not a cron edit.
#
# Read-Only, No Side Effects
# Writes nothing, changes nothing, triggers nothing. Safe to run at any time
# for a health snapshot without affecting any running service or state file.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
@@ -394,4 +394,4 @@ if [[ "$DRY_RUN" == true ]]; then
elif [[ "$SHOULD_SEND" == true ]]; then
notify "$NOTIFY_MSG" "Health Digest" "$NOTIFY_SEV"
echo "Digest sent"
fi
fi
Executable → Regular
+19 -19
View File
@@ -15,6 +15,24 @@
# comparison. In --dry-run mode, console only — nothing written to the log.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Five report sections (each skips gracefully if its data source is unavailable):
#
# ZFS pool health — status, state, errors per pool. Pools in
# ZFS_REPORT_IGNORE_POOLS excluded from the report
# (still fully monitored by unRAID — report-only exclusion).
# ARC statistics — current ARC vs max, metadata pressure, hit rate.
# Warns if ARC utilisation exceeds ZFS_REPORT_ARC_WARN_PCT, or if
# ARC headroom (max - current) drops below ZFS_REPORT_ARC_FREE_WARN_GB.
# Memory status — total, free, available RAM (informational only — see note below).
# Warns if available < ZFS_REPORT_AVAIL_WARN_GB.
# Docker memory — top ZFS_REPORT_DOCKER_TOP containers by memory usage.
# Useful for spotting containers approaching watchdog limits.
# Kernel pressure — vmstat snapshot (3 samples).
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
@@ -42,24 +60,6 @@
# reclaimable cache) is still checked separately as a true system-pressure signal.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Five report sections (each skips gracefully if its data source is unavailable):
#
# ZFS pool health — status, state, errors per pool. Pools in
# ZFS_REPORT_IGNORE_POOLS excluded from the report
# (still fully monitored by unRAID — report-only exclusion).
# ARC statistics — current ARC vs max, metadata pressure, hit rate.
# Warns if ARC utilisation exceeds ZFS_REPORT_ARC_WARN_PCT, or if
# ARC headroom (max - current) drops below ZFS_REPORT_ARC_FREE_WARN_GB.
# Memory status — total, free, available RAM (informational only — see note below).
# Warns if available < ZFS_REPORT_AVAIL_WARN_GB.
# Docker memory — top ZFS_REPORT_DOCKER_TOP containers by memory usage.
# Useful for spotting containers approaching watchdog limits.
# Kernel pressure — vmstat snapshot (3 samples).
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
@@ -390,4 +390,4 @@ fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ ${#WARNINGS[@]} -gt 0 ]] && exit 1
exit 0
exit 0
+5 -4
View File
@@ -110,10 +110,11 @@ naming every card on the Monitor grid, its width and its order, and generates th
from it — the column ladder, the span clamps at each width, the row-height cap, and the
compensation when a conditional card is absent. Column counts are 8/4/2/1 and spans are 1/2/4/8,
which is what lets the board re-cut itself at any width with no holes and no hand-placed card.
Breakpoints are arithmetic over `VV_MON_CARD_FLOOR`, never chosen by eye. `pages/monitor.php`
carries the card bodies and nothing about where they go; moving a card is moving a line in that
array. The page cross-checks the declaration against the cards that actually rendered and says so
in the browser if they disagree.
Breakpoints are arithmetic over `VV_MON_CARD_FLOOR`, never chosen by eye, and the row-height cap
is conditional on the same arithmetic — it applies only where a rung fits one screen.
`pages/monitor.php` carries the card bodies and nothing about where they go; moving a card is
moving a line in that array. The page cross-checks the declaration against the cards that
actually rendered and says so in the browser if they disagree.
**Caching.** Several endpoints serve from `$VV_CACHE_DIR` (`/tmp/varaverk/api`, tmpfs) rather than hitting live
APIs on every page view, refreshed by `Tools/api_cache_writer.sh`. `?live=1` bypasses the
@@ -24,6 +24,22 @@
# list and reboot.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# One pass over PCIE_QUIET_DEVICES at array start. Each address is looked up under
# /sys/bus/pci/devices, checked against the guards below, and removed through the kernel's own
# per-device remove attribute. Nothing is masked and no kernel parameter is set.
#
# Idempotent, and silent when there is nothing to do. An address that is already gone is not an
# error — that is the normal state on every array start after the first within one boot.
#
# Reapplied every array start rather than once, because a reboot re-enumerates the bus and the
# devices come back. That is also the undo: clear PCIE_QUIET_DEVICES and reboot.
#
# PCIE_QUIET_ENABLED gates the whole run before any device is touched.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
+14 -14
View File
@@ -18,6 +18,19 @@
# total. Too high wastes RAM; too low causes slowdowns.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Read the current pm.max_children from the PHP-FPM pool config
# 2. Already at or above the target → exit silently, no write, no restart
# 3. Otherwise rewrite the value and restart PHP-FPM via the adapter
# 4. Verify PHP-FPM came back up
#
# Runs at array start, before the WebGUI sees real load. The setting does not survive an
# unRAID update — the OS replaces the pool config — which is why this reapplies every boot
# rather than being a one-time install step.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
@@ -41,19 +54,6 @@
# 7. Read back config to confirm value applied
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Read the current pm.max_children from the PHP-FPM pool config
# 2. Already at or above the target → exit silently, no write, no restart
# 3. Otherwise rewrite the value and restart PHP-FPM via the adapter
# 4. Verify PHP-FPM came back up
#
# Runs at array start, before the WebGUI sees real load. The setting does not survive an
# unRAID update — the OS replaces the pool config — which is why this reapplies every boot
# rather than being a one-time install step.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
@@ -257,4 +257,4 @@ echo ""
echo "$ICON_DONE Status: done ✅"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
exit 0
+14 -14
View File
@@ -14,20 +14,6 @@
# page always reflects the live key value.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Self-Healing at Boot
# The unraid-api registry is ephemeral — OS updates and service restarts clear
# it without warning. Running at every array start means the key is always
# present after boot without any manual intervention.
#
# Conf Stays Current
# HOST*_UNRAID_API_KEY in the local host conf is updated after every renewal.
# The partnership page reads the conf — it always reflects the live key value
# without a separate sync step.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
@@ -42,6 +28,20 @@
# a key present in the conf but absent from the registry is the exact failure it repairs.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Self-Healing at Boot
# The unraid-api registry is ephemeral — OS updates and service restarts clear
# it without warning. Running at every array start means the key is always
# present after boot without any manual intervention.
#
# Conf Stays Current
# HOST*_UNRAID_API_KEY in the local host conf is updated after every renewal.
# The partnership page reads the conf — it always reflects the live key value
# without a separate sync step.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
+9 -6
View File
@@ -16,12 +16,7 @@
# Not scheduled and deliberately not in any orchestrator. This is a development check — it runs
# when the routing changes, not every night. Nothing on the running system depends on it.
#
# RUNTIME MODES
# ai_explain_check.sh check every fixture
# ai_explain_check.sh --verbose print the full explain report for each case
# ai_explain_check.sh <pattern> only cases whose question matches the pattern
#
# OPERATIONAL SAFEGUARDS
# DESIGN PRINCIPLES
# Asserts routing, never wording.
# Which capabilities a profile holds and which evidence was attached are decided before the
# model is asked anything. Asserting on generated prose would fail for reasons that tell
@@ -31,8 +26,16 @@
# An unrecognised key is an error, not a skip. A typo in an assertion that silently passes
# is worse than no assertion, because the line still reads as covered.
#
# OPERATIONAL SAFEGUARDS
# Read-only. Runs fixtures through --explain and compares; writes nothing and changes no state.
#
# Exits non-zero on any failure, so it can gate a commit.
#
# RUNTIME MODES
# ai_explain_check.sh check every fixture
# ai_explain_check.sh --verbose print the full explain report for each case
# ai_explain_check.sh <pattern> only cases whose question matches the pattern
#
# DEPENDS ON
# Plugin/unraid/Tools/ai_chat_worker.php --explain mode
# Plugin/unraid/Tools/ai_explain_fixtures.txt
+28 -4
View File
@@ -8,10 +8,34 @@
// Reads ai_log_fixtures.txt and this host's /var/log/syslog*. Files nothing, writes nothing,
// and calls no part of the sweep beyond vv_ai_syslog_findings() on lines it supplies itself.
//
// EXIT
// 0 when every fixture is recognised as written. Precision findings are reported but never
// fail the run: what a real syslog contains is a fact about the machine, not about the
// patterns, and a genuinely failing disk should not turn this into a red test.
// DESIGN PRINCIPLES
// Only recall can fail the run.
// A missed fixture is a fact about the patterns and is always a defect. A precision hit is
// a fact about this machine — a genuinely failing disk should not turn this red, and if it
// did, the honest fix would be to stop having a failing disk rather than to edit a pattern.
//
// Precision is replayed against real history, not a sample.
// The patterns that cause damage are the ones matching ordinary operation, and ordinary
// operation is exactly what a hand-written fixture file never contains. Only the machine's
// own syslog can show what a pattern fires on when nothing is wrong.
//
// The sweep is never invoked, only its matcher.
// vv_ai_syslog_findings() is called on lines this file supplies. Running the real sweep
// would file findings, and a test that has to be cleaned up afterwards stops being run.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Reads ai_log_fixtures.txt and this host's /var/log/syslog*; files no finding,
// writes no store, and touches no conf beyond the enable flag.
//
// Exit 0 when every fixture is recognised as written. Precision findings are reported but
// never fail the run — see DESIGN PRINCIPLES.
//
// RUNTIME MODES
// php ai_log_check.php both checks
// php ai_log_check.php --recall fixtures only
// php ai_log_check.php --precision replay this host's syslog history only
//
// Not scheduled, and deliberately so. Run it after touching VV_AI_SYSLOG_PATTERNS.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/ai_repair.php';
+41
View File
@@ -15,6 +15,47 @@
#
# Run it after touching VV_AI_SYSLOG_PATTERNS. Nothing here writes: no findings are filed, no
# conf is read for anything but the enable flag, and the sweep is never invoked.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# A wrapper. The work is in ai_log_check.php, next to the sweep's own matcher — the patterns and
# vv_ai_syslog_findings() live in include/ai_repair.php, and a bash reimplementation of the
# matching would be a second set of regexes to keep in step with the first.
#
# Flags are forwarded verbatim; nothing is interpreted here.
#
# Not scheduled and in no orchestrator. This is a development check that runs when the patterns
# change, not on a timer — nothing on the running system depends on it.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Two checks, because the failure modes are opposite.
# Recall catches a pattern that stopped matching; precision catches one that matches too much.
# A single test would catch one and silently permit the other, and the second is the one that
# fills the findings store with noise until the operator stops reading it.
#
# Precision is measured against this machine's real history.
# A hand-written fixture file can show that a pattern matches what it should. Only a real
# syslog can show what it also matches when nothing is wrong.
#
# Only recall fails the run.
# What a real syslog contains is a fact about the machine, not about the patterns. A genuinely
# failing disk should not turn this red.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Read-only. No finding is filed, no store is written, and the repair sweep itself is never run
# — only its matcher, on lines this check supplies.
#
# Safe to run on a live host at any time, including one that is currently faulting. It observes
# the syslog it replays and changes nothing about it.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
+5
View File
@@ -42,6 +42,11 @@
// stdin is an untrusted stream from another process. A malformed or endless payload must
// fail as a bad request, not as an out-of-memory.
//
// RUNTIME MODES
// Not invoked by hand. include/ai_rpc.php opens an SSH session to the owner and runs this
// file with the request as JSON on stdin; the response is JSON on stdout. There are no flags
// and no arguments — the action, the profile and the payload all arrive in the request body.
//
// DEPENDS ON
// include/ai_actions.php vv_ai_dispatch() — the shared handlers
// include/config.php vv_ai_is_owner()
+7
View File
@@ -66,6 +66,13 @@
// VV_CACHE_DIR/arrs.json consumed by api/arrs.php
// stdout one timing line, captured into the job log
//
// RUNTIME MODES
// api_cache_writer.php
// Build both payloads and write them to the cache. Takes no arguments.
//
// Run every minute by Tools/api_cache_writer.sh, which is the cron entry — this file is the
// work, that one is the schedule, the lock and the log.
//
// DEPENDS ON
// include/monitor.php, include/common.php, include/unraid_api.php,
// include/vms.php, include/docker_folders.php, include/arrs.php
+32
View File
@@ -28,6 +28,38 @@
# finding for every hostname on the machine.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# It answers the questions nobody was going to ask.
# Both checks were already possible from the Auth tab, and both required somebody to open it
# and press a button on the right row. That is why one host here returned nothing but 5xx for
# months. A check that depends on being run is a check that is not running.
#
# Files findings; never applies remedies.
# The fixes are "start a container", "edit a rule", "change a default policy". Each of those
# is a judgement about intent, and a sweep that guessed at intent would be repairing a
# deliberate configuration as often as a broken one.
#
# Down is measured in time, not in samples.
# A host must have been failing for longer than AUTH_SWEEP_DOWN_MIN before anything is filed.
# Counting failed samples instead would file a finding for every hostname on the machine every
# time it reboots, and a report that cries wolf after every restart stops being read.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Read-only against the auth stack. Nothing here starts a container, edits a proxy host, changes
# a rule or alters a default policy — the two questions are answered by observation alone.
#
# --dry-run reports what it would file and writes no finding. --report only reads what is
# already filed, and is silent when clean, so it cannot manufacture noise for the Sunday report.
#
# Findings are filed, not notified. A sweep that raised an alert per host would be its own
# incident every time the array restarted.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
+34
View File
@@ -25,6 +25,40 @@
# own created_on, which is a real date; nothing else is back-filled.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# History is accumulated, never reconstructed.
# NPM holds only the present, so every past state this file knows about is one it observed at
# the time. Counts start at zero on first run and nothing is back-filled — first_seen is the
# single exception, seeded from NPM's own created_on because that is a real recorded date
# rather than an inference.
#
# A domain is retired on strikes, not on a single bad pass.
# A pass fails for a domain when it is absent from NPM's list or its expiry is already in the
# past — and NPM's list can come back short for reasons that have nothing to do with the
# certificate, such as an API hiccup or a restart mid-pass. CERT_HISTORY_STRIKES consecutive
# failures are required before a domain is retired, and it stays in the store afterwards, so
# one bad read can neither erase months of history nor hide a genuine expiry.
#
# The wrapper holds no logic.
# The work sits next to the NPM client it needs, because token handling lives in
# include/auth.php and a bash reimplementation would be a second copy of the thing most worth
# having only one of. Flags are forwarded verbatim.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# The store is replaced atomically: temp file, verified, then renamed over the original. A pass
# that fails partway leaves the previous history intact rather than a truncated file — this is
# the only record of what these certificates did, and there is no second copy to restore from.
#
# --dry-run reports every change it would make and writes nothing. --status only reads.
#
# Read-only against NPM. Certificates are observed; nothing here renews, deletes or edits one.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
+15
View File
@@ -40,6 +40,21 @@
// downstream of something else here, and listing it alongside its own cause invites fixing the
// symptom.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Opens certbot's logs and nothing else — no certificate is requested, renewed or
// deleted here, and no log is rotated or truncated.
//
// Bounded by file count and by bytes per file, so a directory that has grown to 639 MB across
// 1001 files cannot turn a page load into an unbounded read. Only the tail of each log is
// examined, because a run explains its failure at the end rather than the beginning.
//
// An unrecognised failure is reported as unclassified, never folded into the nearest category.
// A tidy chart that is wrong sends the operator to fix a domain that was never broken.
//
// The log directory is discovered from the NPM container rather than assumed, so a container
// path change surfaces as "no logs found" instead of an empty triage that reads as "no
// failures".
//
// RUNTIME MODES
// cert_triage.php summary — categories, affected domains, and the causal reading
// cert_triage.php --json the same as JSON, for the Certs tab
+36
View File
@@ -30,6 +30,42 @@
# fails renewals for domains that have nothing wrong with them.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# One invocation is one data point.
# A single failure writes its reason three times — the ACME response, the traceback, and
# certbot's summary. Counting lines would report it as three and inflate whichever category
# happens to be the most verbose, which is the opposite of what triage is for.
#
# Causes are separated from consequences.
# Rate limiting is nearly always downstream: retries against a hostname with no DNS record
# exhaust the allowance, which then fails renewals for domains that have nothing wrong with
# them. Reporting the rate limit as the problem sends the operator to fix the wrong domains.
#
# Rotation suffix is the clock, not mtime.
# Every file in this directory carries the same mtime because they arrive as a synced set, so
# ordering by mtime returns an arbitrary answer that looks authoritative.
#
# Bounded by design, not by hope.
# 639 MB across 1001 files cannot be read in a page load. Only CERT_TRIAGE_FILES logs are
# opened, and only CERT_TRIAGE_MAX_BYTES from the end of each, because a failure explains
# itself at the end of the run rather than the start.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Read-only throughout. It opens certbot's logs and nothing else — no certificate is requested,
# renewed or deleted, and no log is rotated or truncated by anything here.
#
# Diagnosis only. This names why renewals failed; acting on that is the operator's, and
# cert_history.sh remains the thing that records what happened.
#
# The log directory is normally discovered from the NPM container rather than hardcoded, so a
# container path change surfaces as "no logs found" rather than as a silently empty triage.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
+32 -9
View File
@@ -13,16 +13,39 @@
// php Tools/conf_widget_check.php assertions, then the live summary
// php Tools/conf_widget_check.php --list every live field and its inferred control
//
// WHY IT ASSERTS AGAINST SNIPPETS AND NOT THE LIVE CONF
// The live conf is the thing being described, so it cannot also be the thing that proves the
// description right — an inference rule that silently stopped matching would keep passing as
// the conf drifted to suit it. The snippets are frozen copies of each convention as written,
// so a rule change that breaks one shows up here rather than as a wrong control on a page.
// DESIGN PRINCIPLES
// Assertions run against snippets, never against the live conf.
// The live conf is the thing being described, so it cannot also be the thing that proves
// the description right — an inference rule that silently stopped matching would keep
// passing as the conf drifted to suit it. The snippets are frozen copies of each
// convention as written, so a rule change that breaks one shows up here rather than as a
// wrong control on a page.
//
// WHAT AN INFERENCE IS NOT
// Consistent with confform.php, none of this validates. A number field carrying min and max is
// a courtesy to whoever is typing, not a promise the value is sensible — the consuming script
// still owns that question.
// An inference is a drawing decision, not a validation.
// Consistent with confform.php, none of this validates. A number field carrying min and
// max is a courtesy to whoever is typing, not a promise the value is sensible — the
// consuming script still owns that question.
//
// The live pass reports, it does not assert.
// What this host's master.conf infers to is a description of that file, not a verdict on
// it. Turning the live summary into pass/fail would make an unusual but legitimate
// setting look like a defect.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Parses conf and reports; writes no conf, no store and no page.
//
// Exits non-zero only when a snippet assertion fails, so it can gate a commit without a real
// conf's contents ever being able to break the build.
//
// Never renders. It reports which control would be drawn; the drawing stays in confform.php,
// so this cannot disagree with the page by construction.
//
// RUNTIME MODES
// php Tools/conf_widget_check.php assertions, then the live summary
// php Tools/conf_widget_check.php --list every live field and its inferred control
//
// Hand-run. Not scheduled and in no orchestrator — run it after touching _vv_conf_widget(),
// after adding a conf convention, or when a setting draws as the wrong control.
//
// DEPENDS ON
// include/confform.php _vv_conf_parse_field_range(), vv_conf_key_is_secret()
+14 -1
View File
@@ -9,6 +9,19 @@
// Onboard calls this on the mirror after deploying the owner's stack, so those containers land
// somewhere that says whose they are instead of scattering into the mirror's own groups.
//
// OPERATIONAL MODEL
// Create-or-update, never replace. The named folder is found in folder.view3 if it exists and
// is amended in place; only its absence causes one to be created. Every other folder in the
// file, and every container already filed elsewhere, is left exactly as it was — this is one
// shelf in someone else's cupboard.
//
// Called during onboard on the mirror, after the owner's stack has been deployed there, so the
// containers exist by the time anything tries to file them.
//
// The icon is resolved separately from the folder and never gates it. --icon-only performs
// just that lookup and prints the URL, which is how onboard asks for it without writing
// anything.
//
// DESIGN PRINCIPLES
// The name comes from master.conf, never from a second source.
// HOST2="unRAID-Jayred36" → "Jayred36-Fallback". The convention already existed by hand as
@@ -39,7 +52,7 @@
// --dry-run prints the resulting folder and writes nothing.
// Icon resolution failing never blocks the folder: no image is a cosmetic loss, no folder is not.
//
// REQUEST
// RUNTIME MODES
// fallback_folder.php --host=HOST2 [--containers=A,B,C] [--icon=URL] [--dry-run]
// fallback_folder.php --host=HOST2 --icon-only resolve and print the icon URL, write nothing
//
@@ -44,6 +44,17 @@
// php mesh_traffic_sample.php append one sample per mesh peer
// php mesh_traffic_sample.php --show print what it would record, write nothing
//
// CONFIGURATION
// master.conf
// HOST1, HOST2, … the hostname of each node in the mesh. Every HOST<n> with a non-empty
// value becomes a name matched against tailscale's peer list; a peer that
// is not one of them is skipped, so a device that merely shares the
// tailnet is never recorded as mesh traffic. No HOST<n> set at all means
// there is no mesh to sample and the run exits without writing.
//
// Nothing is read from host*.conf. This samples the local tailscale daemon, so it needs to
// know which peers count and nothing about how to reach them.
//
// DEPENDS ON
// tailscale status --json the counters
// include/config.php DATA_DIR, vv_conf_vars() for the HOST* list
+16
View File
@@ -63,6 +63,22 @@
// "unfiled":[…]}
// import: one line per folder written, then a count
//
// RUNTIME MODES
// mirror_folders.php --export
// Print this host's folder layout as JSON, for the owner to compute a plan from.
//
// mirror_folders.php --import
// Read a layout on stdin and apply it to this host's folder.view3.
//
// mirror_folders.php --containers=<csv>
// Restrict the operation to these container names.
//
// mirror_folders.php --fallback-only=<csv>
// Place only these into the partner's fallback folder, leaving the rest where they are.
//
// --dry-run
// Compute and report the plan without writing folder.view3. Combines with any of the above.
//
// DEPENDS ON
// /boot/config/plugins/folder.view3/docker.json the third-party layout, optional on import
// SCRIPTS_DIR/docker_folders.json Varaverk's own layout — what the card reads
+22 -3
View File
@@ -5,12 +5,25 @@
// vv_nc_deliver(), with the record on stdin.
//
// OPERATIONAL MODEL
// php node_chat_receive.php --channel=<id> record on stdin, one JSON object
// php node_chat_receive.php --flush retry this host's own undelivered spool
//
// Exit 0 means stored. The sender treats anything else as undelivered and spools for retry, so
// a non-zero exit here is a message that will arrive later rather than one that is lost.
//
// Two jobs in one file because they are two halves of the same contract: --channel takes what
// a partner is delivering now, --flush pushes what this host failed to deliver earlier. A node
// is both a receiver and a sender, and splitting them would mean two files that must agree on
// the same spool layout.
//
// DESIGN PRINCIPLES
// The exit code is the receipt, and it is the only one.
// There is no acknowledgement message and no reply body. The sender is an SSH command that
// already has an exit status, so inventing a second channel to say the same thing would
// give the two ways to disagree.
//
// Refusing is cheaper than being wrong.
// An unparseable record, an unknown channel or a failed write all exit non-zero and leave
// the message in the sender's spool. A retry costs one SSH round trip; a message accepted
// and dropped is gone with nothing recording that it existed.
//
// OPERATIONAL SAFEGUARDS
// Reached only over SSH with a key this mesh installed, so the caller already has root. This
// file therefore validates shape, not authority — there is no privilege here to protect that
@@ -22,6 +35,12 @@
// Storage is append-and-trim through vv_nc_append(), which is idempotent on message id — a
// retry of something that already landed is a no-op rather than a duplicate.
//
// RUNTIME MODES
// Not invoked by hand — the sending node's vv_nc_deliver() runs it over SSH.
//
// php node_chat_receive.php --channel=<id> store one JSON record, read from stdin
// php node_chat_receive.php --flush retry this host's own undelivered spool
//
// DEPENDS ON
// include/node_chat.php vv_nc_append(), vv_nc_channel(), vv_nc_flush_spool()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
+36
View File
@@ -22,6 +22,42 @@
# passes are not counted. Running daily keeps that to whatever NPM rotates in a day.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Incremental by byte offset, never a re-read.
# 475 MB across 41 files cannot be parsed on a page load, and re-parsing what was already
# counted would make each pass slower than the last. Each run records where it stopped and
# resumes there, so cost tracks new traffic rather than total traffic.
#
# A rotated log is detected, not assumed.
# A file smaller than the offset already recorded for it means NPM rotated it beneath us, so
# that file's offset restarts at zero and a rotation is counted. The totals are cumulative and
# are never reset by it — what rotated out between two passes is simply not counted, which is
# why this runs often enough to keep that gap to whatever NPM rotates in a day.
#
# Varaverk's own probes are excluded.
# uptime_probe.sh requests every proxied host every minute under Varaverk-Uptime/1.0. Counting
# those would make the monitor the busiest client of everything it monitors.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Read-only against NPM. Access logs are read and never rotated, truncated or removed — that is
# NPM's business, and a stats collector that deleted its own inputs would be unrecoverable.
#
# --dry-run parses and reports without writing the store or advancing any offset, so a dry run
# leaves the next real pass with exactly the same work to do.
#
# --reset forgets offsets and totals deliberately. It starts from the current end of each log,
# so it discards history rather than double-counting it.
#
# The store is replaced atomically: written to a temp file, re-read and parsed to prove it is
# valid JSON, then renamed over the original. A pass that dies midway — or produces something
# unparseable — leaves the previous document untouched rather than a truncated one.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
+13 -13
View File
@@ -15,19 +15,6 @@
# Accepts --host=HOST2 to refresh a single host (used by the UI refresh button).
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Cache-First, Never Live on Page Load
# Remote arr APIs have non-trivial latency — calling them on every page view
# would make the arrs page slow and fragile. Writing to /tmp/vv_cache/ on a
# 2-hour schedule decouples page load time from network availability.
#
# Single-Host Refresh for UI
# The UI refresh button passes --host=HOSTN to update one host without waiting
# for the full 2-hour cycle. Keeps the cache fresh when a user requests it.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
@@ -44,6 +31,19 @@
# back to live calls when a file is missing or stale.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Cache-First, Never Live on Page Load
# Remote arr APIs have non-trivial latency — calling them on every page view
# would make the arrs page slow and fragile. Writing to /tmp/vv_cache/ on a
# 2-hour schedule decouples page load time from network availability.
#
# Single-Host Refresh for UI
# The UI refresh button passes --host=HOSTN to update one host without waiting
# for the full 2-hour cycle. Keeps the cache fresh when a user requests it.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
+40 -22
View File
@@ -5,31 +5,49 @@
// to reach it. Generated so the assistant can answer "how do I change X" with a path through
// the pages instead of an instruction to open master.conf.
//
// WHY THE ASSISTANT NEEDS THIS AT ALL
// The retrieval index reads git-tracked files. PHP body markup is not indexed and would be
// useless if it were — a page is a pile of divs, not a description of itself — so the assistant
// has never had any way to know the UI exists. It could name a conf key and nothing more.
// pages/readme/*.md is the one directory the chunker classifies as kind='ui', which is why the
// output lands there and not in docs/.
//
// WHY IT IS GENERATED
// A hand-written map is a second description of the pages, and the moment a card moves it
// starts lying with total confidence — which is worse than saying nothing, because the
// assistant will repeat it. Everything here is derived from the same registries the pages
// themselves are built from: VV_SCRIPT_CONF_SECTIONS for what the Scheduler shows per script,
// VV_UI_SECTION_SURFACES for the pages that show sections by subject, and the conf files for
// the settings and their controls.
//
// OPERATIONAL MODEL
// Hand-run, and re-run after adding a conf section, a script mapping or a settings surface.
// Writes exactly one file and nothing else.
// Reads the section registries and the conf files, resolves each setting to the page and card
// that renders it, and writes the whole map in one pass. Nothing is merged with what is
// already there — the output is derived entirely from the registries, so a stale entry cannot
// survive a rebuild.
//
// php Tools/ui_map_build.php write the map
// php Tools/ui_map_build.php --check report what it would change, write nothing
// DESIGN PRINCIPLES
// The assistant cannot see the UI any other way.
// The retrieval index reads git-tracked files. PHP body markup is not indexed and would be
// useless if it were — a page is a pile of divs, not a description of itself — so the
// assistant has never had any way to know the UI exists. It could name a conf key and
// nothing more. pages/readme/*.md is the one directory the chunker classifies as
// kind='ui', which is why the output lands there and not in docs/.
//
// Only sections that are genuinely reachable are listed. A section no page renders is reported
// at the end as unreachable rather than silently omitted — a setting with no route is a real
// finding, and the map is the only place that would notice.
// Generated, because a hand-written map lies with confidence.
// A second description of the pages starts being wrong the moment a card moves, and that
// is worse than saying nothing, because the assistant will repeat it. Everything here is
// derived from the same registries the pages themselves are built from:
// VV_SCRIPT_CONF_SECTIONS for what the Scheduler shows per script, VV_UI_SECTION_SURFACES
// for the pages that show sections by subject, and the conf files for the settings and
// their controls.
//
// An unreachable section is reported, never dropped.
// A section no page renders is listed at the end rather than silently omitted. A setting
// with no route through the UI is a real finding, and this map is the only thing that
// would ever notice.
//
// OPERATIONAL SAFEGUARDS
// Writes exactly one file, pages/readme/ui-map.md, and nothing else. No conf is modified, no
// page is touched, and the registries it reads are only read.
//
// --check reports what would change and writes nothing, so the map can be verified current in
// a commit without regenerating it.
//
// Generated output only. Nothing hand-edited belongs in ui-map.md — an edit there is lost on
// the next run, which is the correct behaviour for a derived file and the reason the header
// says so.
//
// RUNTIME MODES
// php Tools/ui_map_build.php write the map
// php Tools/ui_map_build.php --check report what it would change, write nothing
//
// Hand-run. Re-run after adding a conf section, a script mapping or a settings surface.
//
// DEPENDS ON
// include/confform.php the section registries, the parser, and the inferred controls
+33
View File
@@ -25,6 +25,39 @@
# that, this monitor becomes fifty thousand requests a day in the logs it reports on.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Probe what is served, not what was visited.
# The domain list comes from NPM's own proxy hosts, so a host nobody has requested is measured
# exactly like a busy one. Deriving the list from traffic would leave the quietest hosts — the
# ones most likely to be broken without anyone noticing — permanently unmonitored.
#
# The probe is excluded from the statistics it feeds.
# Every request carries Varaverk-Uptime/1.0, which npm_access_stats.sh filters out. Without
# that the monitor would be the largest single source of traffic in the logs it reports on,
# and every access figure would be measuring this script.
#
# The wrapper holds no logic.
# Flags are forwarded verbatim and nothing is interpreted here. Two places that both know what
# --dry-run means is two places that can disagree about it.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# UPTIME_PROBE_ENABLED gates the whole run. Off means no probes and no writes.
#
# Bounded per domain by UPTIME_PROBE_TIMEOUT, so an unresponsive host costs one timeout rather
# than stalling the pass — the probes run in parallel, so one slow domain never delays the rest.
#
# --dry-run probes and reports without writing. --status and --events read stored history and
# probe nothing, so neither can alter what it is describing.
#
# Read-only against NPM. The domain list is read; no proxy host, certificate or setting is
# touched by anything in this path.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
+1 -1
View File
@@ -170,7 +170,7 @@ if (is_dir(LOG_DIR)) {
if ($lastErr !== null)
$errors[] = ['script' => $script, 'line' => $lastErr, 'ts' => (int)$lf->getMTime()];
}
} catch (Exception $e) {}
} catch (Exception $e) { vv_log_error('api/board.php', 'log walk failed: ' . $e->getMessage()); }
usort($errors, fn($a, $b) => $b['ts'] - $a['ts']);
}
$out['errors'] = array_slice($errors, 0, 20);
+7
View File
@@ -5,6 +5,13 @@
// state, tier activation, handback strikes, covered container status — for the fallback
// tab's 30s poll.
//
// OPERATIONAL MODEL
// Computed fresh on every request, deliberately uncached. The inputs are small local state
// files that fallback.sh rewrites as it moves between states, so assembling them costs about
// nothing — and a cached fallback picture is the one kind of stale this tab must never serve.
// A page showing NORMAL because the answer was cached before the switch is worse than a page
// that took an extra moment to load.
//
// DESIGN PRINCIPLES
// Thin transport. Every judgement about what a state file means lives in
// include/fallback.php. This file exists to give the browser a URL.
+16 -11
View File
@@ -4,12 +4,19 @@
// Active media sessions endpoint. Normalised now-playing across every Emby, Jellyfin and
// Plex instance configured for this host, for the monitor page's session panel.
//
// OPERATIONAL MODEL
// Local is the default and stays the cheap path: one call per media server configured on this
// host. Mesh adds one bounded SSH hop per partner and is only requested while the operator is
// looking at the mesh view, so a dashboard left open on the default costs exactly what it did
// before the scope existed.
//
// DESIGN PRINCIPLES
// Thin transport. Discovery, per-server API dialects and normalisation all live in
// include/media.php; this file only sets the content type and encodes the result.
//
// No parameters. Which servers to ask is derived from conf, not from the request, so the
// browser cannot point this endpoint at an arbitrary URL.
// The request chooses a scope, never a target. scope= selects local or mesh; which servers
// are asked, and which partners the mesh hop reaches, are both derived from conf. The browser
// can widen what it asks for, but it cannot point this endpoint at an arbitrary URL.
//
// OPERATIONAL SAFEGUARDS
// Bounded by the library's 3s per-request timeout.
@@ -25,18 +32,16 @@
// messages a client.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// vv_media_sessions() verbatim — a flat list of normalised sessions across all servers
//
// REQUEST
// GET this host's sessions
// GET ?scope=mesh every node's sessions, each row tagged with the host it is playing on
//
// Local is the default and stays the cheap path: one call per configured media server here.
// Mesh adds one bounded SSH hop per partner and is only requested while the operator is looking
// at the mesh view, so a dashboard left open on the default costs exactly what it did before.
// RESPONSE
// local {"scope":"local","sessions":[…],"server_names":[…],"server_count":N}
// mesh {"scope":"mesh","nodes":[…],"sessions":[…],"server_names":[…],"server_count":N}
//
// sessions is the normalised list; in the mesh scope each row also carries the host it is
// playing on. server_count counts media servers, not sessions. There is no error shape — an
// unreachable server contributes nothing; see OPERATIONAL SAFEGUARDS.
//
// DEPENDS ON
// include/media.php vv_media_sessions(), vv_media_sessions_mesh()
+29 -6
View File
@@ -4,12 +4,19 @@
// The Partnership tab's mesh chat: read a channel, post to it, forget a message on this
// machine, and mark a channel read.
//
// REQUEST
// GET channels + this host's id + unread counts
// GET ?channel=<id> that channel's messages
// POST action=send channel=<id> text=… [color=#rrggbb] [font=mono|sans|serif]
// POST action=delete channel=<id> id=<msgid> local only
// POST action=read channel=<id> mark seen up to now
// OPERATIONAL MODEL
// The method is the routing. Anything that is not a POST is a read — channel list or one
// channel's messages — and every POST carries an action. That keeps the CSRF boundary and the
// read/write boundary on the same line, so a mutation cannot arrive un-covered by being
// spelled as a GET.
//
// Reads are computed per request, not cached. Unread counts come from walking the last 200
// messages of each channel against this host's read mark; the card polls on a slow cycle and
// the store is a small append log, so a cache would add a staleness class for no gain.
//
// Delivery is not part of the response's success. vv_nc_send() writes locally and spools for
// any partner that could not be reached, and the reply carries that spool depth as `queued`.
// A sleeping partner is a pending message, not a failed one.
//
// DESIGN PRINCIPLES
// Read marks are local and per channel. "Unread" is a fact about this operator at this
@@ -28,6 +35,22 @@
// locally and spooled for retry; saying "failed" over something that is stored and queued
// would be the wrong claim.
//
// REQUEST
// GET channels + this host's id + unread counts
// GET ?channel=<id> that channel's messages
// POST action=send channel=<id> text=… [color=#rrggbb] [font=mono|sans|serif]
// POST action=delete channel=<id> id=<msgid> local only
// POST action=read channel=<id> mark seen up to now
//
// RESPONSE
// {"ok":true,"me":<host>,"channels":[{…,"unread":N}],"hostnames":{id:name}} channel list
// {"ok":true,"me":<host>,"channel":<id>,"messages":[…],"last_read":<ts>} one channel
// {"ok":true,"msg":{…},"queued":N} send; N = spooled
// {"ok":true|false} delete
// {"ok":true} read
// {"ok":false,"error":"Unknown channel"|"Nothing to send"|"Could not store message"
// |"No message id"|"Unknown action"}
//
// DEPENDS ON
// include/node_chat.php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
+10
View File
@@ -5,6 +5,16 @@
// reachability, SSH trust, conf-sync state and the shared-service inventory — for the
// partnership tab's poll.
//
// OPERATIONAL MODEL
// One call, one document. The endpoint holds no logic and adds no cache of its own — it
// encodes whatever vv_partnership_all() returns, so any freshness policy is the library's and
// there is only one place it can be changed.
//
// The whole picture is assembled per request rather than exposed as separate endpoints for
// hosts, trust and services. Those answers are read together and judged against each other —
// a host that is reachable but has lost SSH trust is a different state from either fact alone,
// and splitting them would let the tab render a combination that never existed at one moment.
//
// DESIGN PRINCIPLES
// Thin transport. Host enumeration, SSH probing and trust evaluation live in
// include/partnership.php; this file only sets the content type and encodes.
+13
View File
@@ -4,6 +4,19 @@
// Script and document reader. Returns the full text of one .sh or .md file inside
// SCRIPTS_DIR — the source view behind the scheduler page's script viewer and the docs tab.
//
// OPERATIONAL MODEL
// Validate, resolve, read. The id is checked against a character class and an extension list
// before it is joined to SCRIPTS_DIR, so nothing reaches the filesystem that did not already
// look like a repo-relative path.
//
// The whole file is returned in one response — no ranges, no pagination. These are scripts and
// documents, not logs; the largest is a few hundred kilobytes, and a viewer that had to stitch
// pages together would be more machinery than the thing it displays.
//
// Every failure is a JSON body with ok:false, never an HTTP error code. The scheduler's viewer
// and the docs tab both parse the response before looking at anything else, so a 404 would
// surface as a parse failure rather than as "that file is not there".
//
// DESIGN PRINCIPLES
// Two extensions, one endpoint.
// Scripts and their READMEs are read the same way because they are read for the same
+1 -1
View File
@@ -76,7 +76,7 @@ try {
'dur' => isset($d['end']) ? max(0, (int)$d['end'] - (int)$d['start']) : 0,
];
}
} catch (Exception $e) {}
} catch (Exception $e) { vv_log_error('api/recent.php', 'run-log walk failed: ' . $e->getMessage()); }
usort($runs, fn($a, $b) => $b['start'] - $a['start']);
echo json_encode(['ok' => true, 'runs' => array_slice($runs, 0, 24)]);
+1 -1
View File
@@ -123,7 +123,7 @@ if ($action === 'list_scripts') {
$label = str_replace('_', ' ', basename($rel, '.sh'));
$groups[$folder][] = ['id' => $rel, 'label' => $label];
}
} catch (Exception $e) {}
} catch (Exception $e) { vv_log_error('api/rsync_win_arrays.php', 'script tree walk failed: ' . $e->getMessage()); }
ksort($groups);
foreach ($groups as &$g) usort($g, fn($a, $b) => strcmp($a['id'], $b['id']));
echo json_encode(['ok' => true, 'groups' => $groups]);
+13 -1
View File
@@ -5,6 +5,16 @@
// resource, docker, system, storage, network and stability — together with the thresholds
// each one is judging against, for the watchdog tab's poll.
//
// OPERATIONAL MODEL
// Served from a 5-minute cache unless ?live is present. Assembling this payload reads every
// watchdog's state files and resolves every threshold out of master.conf, which is far more
// work than the tab's poll needs — the watchdogs themselves only run every 15 minutes, so a
// fresher answer would describe the same cycle.
//
// The cache is consulted before include/watchdog.php is even loaded, so a cache hit costs one
// file read and nothing else. ?live skips the read, recomputes, and writes the result back, so
// an explicit refresh also benefits the next visitor rather than being discarded.
//
// DESIGN PRINCIPLES
// Thin transport. State-file parsing and threshold resolution live in
// include/watchdog.php; this file only sets the content type and encodes.
@@ -15,7 +25,9 @@
// against, so the page never has to fetch the two independently and risk mismatching
// them across a conf edit.
//
// No parameters. Which watchdogs exist is fixed by the codebase, not by the request.
// The only parameter is a freshness override. ?live decides how old an answer may be, never
// what is in it — which watchdogs exist is fixed by the codebase, and no request can select,
// filter or widen the set.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Nothing here clears a strike, lifts a skip-list entry, restarts a container,
+28 -4
View File
@@ -235,6 +235,27 @@ body.vv-fullscreen #displaybox { padding-left: 1rem !important; padding-top: .5r
/* Hide scrollbars on any nested scrollable div inside monitor cards */
#vv-monitor .vv-card div::-webkit-scrollbar { display: none; }
/* System card, held to the Network card's height.
At eight columns these two share a row (system 1 + ups 1 + cpu 2 + memory 2 + network 2), so
the grid already stretches them to the same height and nothing here is needed. Below eight the
board re-cuts into rows of four: System lands in row 1 with UPS and CPU, Network in row 2 with
Memory, and once rows are sized to content (vv_mon_rung_overflows) the two are free to differ.
System is the taller — it carries a host block and a Varaverk block — so it is the one capped.
222px is Network measured rather than guessed: 2 border + 24 padding + 25 h3 + 61 header block
+ 110 canvas. The canvas is a fixed 110px, which is what makes the number worth writing down —
most of Network's height cannot drift. What can is the IP list (LAN/EXT/TS), roughly 18px a row,
so a host resolving fewer of the three leaves System slightly tall against it.
Two ids on the selectors, to outrank #vv-monitor .vv-card from the auto-rows hatch above. That
hatch lifts overflow off every card so content-sized rows can work; this card is the one place
the clamps have to go back on, or a max-height with overflow:visible would draw straight
through the card's own border. */
@media (max-width: 1383px), (max-height: 700px) {
#vv-monitor #vv-system { max-height: 222px; overflow: hidden; }
#vv-monitor #vv-system > div { overflow-y: auto; min-height: 0; }
}
/* Dynamic row heights capped per screen tier — rows size to content, never exceed the cap.
minmax(0, Xpx): track is content-driven but capped; align-items:stretch makes all cards
in a row fill the track, so short cards (Pools, Watchdog) match tall ones (Array).
@@ -264,10 +285,13 @@ body.vv-fullscreen #displaybox { padding-left: 1rem !important; padding-top: .5r
worked, while landscape (851x393) missed it and inherited the four-row cap — 58px cards with
overflow:hidden and scrollbars disabled. Nothing was visible and nothing said so.
The width half is the two-column rung from monitor_board.php (below 696px). It is written as a
literal here because a media query cannot read a custom property; if VV_MON_CARD_FLOOR changes,
this number and the AI row's two below are the only three places that have to follow it. */
@media (max-width: 695px), (max-height: 700px) {
Height only now. The width half was a literal 695 that had to be kept in step with
VV_MON_CARD_FLOOR by hand; monitor_board.php emits these same three rules for every rung whose
row count exceeds VV_MON_ROWS_PER_SCREEN — see vv_mon_rung_overflows() — which covers that band
and the four-column one above it from the arithmetic instead. What is left here is the case the
ladder cannot see: a window wide enough for a rung that does fit a screen, on a screen too short
to give those rows a usable height. */
@media (max-height: 700px) {
#vv-monitor { grid-auto-rows: auto; }
#vv-monitor .vv-card { overflow: visible; }
#vv-monitor .vv-card > div { overflow-y: visible; min-height: auto; }
+9
View File
@@ -1363,6 +1363,15 @@ vv_ai_profiles_script();
profile: profile,
question: q,
history: JSON.stringify(sendable()),
// Where the caller is standing, so a question can say "this setting" and mean it. The
// backend whitelists it and passes it to the worker, which turns it into a location
// line; a page that sets no scope sends '' and the worker omits that line.
//
// Keep this here. The scope-aware dock in 185abdb sent it from the page's own ask call;
// 8eeb4c3 folded that dock into this component and the line did not come with it, so
// for the whole of that window Scheduler computed a scope, re-read it at send time, and
// the model never saw it. Nothing errored — the answer just arrived ungrounded.
scope: (typeof o.scope === 'function' ? o.scope() : (o.scope || '')),
kind: (PROFILES[profile].kind && kindEl) ? kindEl.value : '',
// A checkbox where the page offers one, otherwise whatever the page decides from the
// profile, otherwise on. The Scheduler reasons only when diagnosing: working out what a
+42 -20
View File
@@ -8,31 +8,53 @@
// the prompt on its own. A proposal is filed; the operator accepts or dismisses it; accepted
// text lands in the learned memory slot, which the prompt explicitly ranks BELOW retrieval.
//
// WHY IT IS A PROPOSAL AND NOT A WRITE
// Memory is injected into every future prompt. A model that writes its own memory writes its
// own mistakes, and then reads them back as established fact — growing more confident on each
// turn while the actual source code says otherwise. The cost of a bad proposal has to be one
// dismissal, not a permanently poisoned prompt. This is the same two-gate shape the repair
// system uses, for the same reason: whether something should be remembered is intent, and a
// model cannot prove intent.
// DESIGN PRINCIPLES
// It is a proposal, never a write.
// Memory is injected into every future prompt. A model that writes its own memory writes
// its own mistakes, and then reads them back as established fact — growing more confident
// on each turn while the actual source code says otherwise. The cost of a bad proposal has
// to be one dismissal, not a permanently poisoned prompt. This is the same two-gate shape
// the repair system uses, for the same reason: whether something should be remembered is
// intent, and a model cannot prove intent.
//
// WHY DEDUP IS NOT THE MODEL'S JOB
// "Do I already know this" is a semantic comparison, and a 14B at IQ4_XS is confidently wrong
// at it often enough to matter — with every miss costing budget permanently. So dedup here is
// deterministic: normalise, then reject on exact match or containment in either direction
// against assisted memory, learned memory, and everything previously dismissed. It will let
// through a reworded duplicate; it will never silently drop something new, and that is the
// right way round for a store the operator reviews anyway.
// Dedup is deterministic, not the model's job.
// "Do I already know this" is a semantic comparison, and a 14B at IQ4_XS is confidently
// wrong at it often enough to matter — with every miss costing budget permanently. So
// dedup here is deterministic: normalise, then reject on exact match or containment in
// either direction against assisted memory, learned memory, and everything previously
// dismissed. It will let through a reworded duplicate; it will never silently drop
// something new, and that is the right way round for a store the operator reviews anyway.
//
// GATES
// AI_MEMORY_LEARN_ENABLED false — nothing is proposed, and the prompt gains nothing
// AI_MEMORY_LEARN_AUTO_ACCEPT false — accepted writes happen only when the operator says so
// The second cannot outrank the first: auto-accept with proposing off does nothing at all.
// OPERATIONAL SAFEGUARDS
// Two gates, and the second cannot outrank the first.
// AI_MEMORY_LEARN_ENABLED off means nothing is proposed at all, so auto-accept with
// proposing off does nothing. Neither defaults to on.
//
// Dismissed rows are kept, not deleted.
// They are the only thing that stops the same suggestion arriving again every night. A
// store that forgot its refusals would re-propose what the operator has already judged.
//
// Nothing here reaches the prompt directly. Accepted text lands in the learned memory slot,
// which the prompt ranks BELOW retrieval — so even an accepted mistake cannot outrank the
// source code it contradicts.
//
// EXPORTS
// Gates vv_ai_mem_learn_enabled(), vv_ai_mem_learn_auto()
// Dedup vv_ai_mem_norm(), vv_ai_mem_known(), vv_ai_mem_is_dup(), vv_ai_mem_is_ui_fact()
// Store vv_ai_mem_dir(), vv_ai_mem_propose(), vv_ai_mem_list(), vv_ai_mem_remove(),
// vv_ai_mem_append(), vv_ai_mem_write_row()
// Operator vv_ai_mem_action()
// — accept or dismiss one proposal. The only entry that changes what a future
// prompt will contain.
//
// CONFIGURATION
// master.conf
// AI_MEMORY_LEARN_ENABLED propose at all. Default false.
// AI_MEMORY_LEARN_AUTO_ACCEPT write accepted text without asking. Default false.
//
// STORE
// data/ai/mem_proposals/<id>.json — one file per proposal, mirroring the findings store.
// States: open | accepted | dismissed. Dismissed rows are KEPT, because they are what stops
// the same suggestion arriving again every night.
// States: open | accepted | dismissed.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/ai.php';
+38 -34
View File
@@ -5,47 +5,51 @@
// shared chat include and every page that renders it — reads it from here instead of
// restating it.
//
// WHY THIS EXISTS
// A profile used to be defined in five places: history depth in api/ai.php, capabilities in
// include/ai.php, label/hint/depth again in the chat's JavaScript, a prompt branch in the
// worker, and a label map in pages/scheduler.php. They had already drifted — the JavaScript
// knew three profiles where PHP knew four, so the shared chat could not offer troubleshoot at
// all and the Scheduler dock hand-rolled its own labels to compensate. The include carried a
// comment telling the next person not to let the two tables diverge, which is a comment doing
// a data structure's job.
// DESIGN PRINCIPLES
// One definition, not five.
// A profile used to be defined in five places: history depth in api/ai.php, capabilities
// in include/ai.php, label/hint/depth again in the chat's JavaScript, a prompt branch in
// the worker, and a label map in pages/scheduler.php. They had already drifted — the
// JavaScript knew three profiles where PHP knew four, so the shared chat could not offer
// troubleshoot at all and the Scheduler dock hand-rolled its own labels to compensate. The
// include carried a comment telling the next person not to let the two tables diverge,
// which is a comment doing a data structure's job.
//
// WHAT LIVES HERE, AND WHAT DELIBERATELY DOES NOT
// Here: anything more than one file needs to agree on — the set of profiles, their labels and
// hints, history depth, capabilities, and whether a profile is offered as a button.
// What lives here is whatever more than one file must agree on.
// The set of profiles, their labels and hints, history depth, capabilities, and whether a
// profile is offered as a button.
//
// Not here: the system prompts. They are long, delicate, and have exactly one consumer, so
// moving them would be churn against the most sensitive text in the subsystem for no reduction
// in duplication. Tools/ai_chat_worker.php still owns them; it just keys off ids validated
// here rather than an if-chain that invents its own vocabulary.
// Not the system prompts. They are long, delicate, and have exactly one consumer, so
// moving them would be churn against the most sensitive text in the subsystem for no
// reduction in duplication. Tools/ai_chat_worker.php still owns them; it just keys off ids
// validated here rather than an if-chain that invents its own vocabulary.
//
// CAPABILITIES ARE PER PROFILE, NOT PER CAPABILITY
// The old table was inverted — capability => [profiles] — which reads well when adding a
// capability and badly when answering the question actually asked at runtime, which is always
// "what can this profile do". Same content, turned the right way round.
// Capabilities are stored per profile, not per capability.
// The old table was inverted — capability => [profiles] — which reads well when adding a
// capability and badly when answering the question actually asked at runtime, which is
// always "what can this profile do". Same content, turned the right way round.
//
// A profile is a contract plus a set of inputs, and the inputs are the half that has to be
// enforced rather than requested. The caps list is that half.
// A profile is a contract plus a set of inputs, and the inputs are the half that has to be
// enforced rather than requested. The caps list is that half.
//
// It exists because the alternative already failed. The same permissions used to live as a
// dozen `$profile === 'varaverk' || $profile === 'troubleshoot'` conditions spread across the
// worker, and answering "may chat ever be shown a log?" meant reading all of them. It could —
// a gate added for run-outcome questions granted it by omission, and the chat profile, whose
// entire value is that it has NOT been shown this installation, was one phrasing away from
// being handed a health sweep and 120 lines of log. Nothing about that was visible at the
// point of the mistake. Here it would have been one missing word on one line.
// OPERATIONAL SAFEGUARDS
// The capability list is the grant, and it is enforced in one place.
// The same permissions used to live as a dozen `$profile === 'varaverk' || $profile ===
// 'troubleshoot'` conditions spread across the worker, and answering "may chat ever be
// shown a log?" meant reading all of them. It could — a gate added for run-outcome
// questions granted it by omission, and the chat profile, whose entire value is that it
// has NOT been shown this installation, was one phrasing away from being handed a health
// sweep and 120 lines of log. Nothing about that was visible at the point of the mistake.
// Here it would have been one missing word on one line.
//
// A capability is permission, not need. varaverk holds 'health' but only attaches it when the
// question looks diagnostic; troubleshoot attaches it always. The gates decide whether an
// input is warranted, this decides whether it is allowed, and a gate can never widen the grant.
// A capability is permission, not need.
// varaverk holds 'health' but only attaches it when the question looks diagnostic;
// troubleshoot attaches it always. The gates decide whether an input is warranted, this
// decides whether it is allowed, and a gate can never widen the grant.
//
// chat holding an empty capability list is a guarantee, not an oversight. Anything added to it
// stops being general chat and becomes an assistant that sometimes lies about this
// installation.
// chat holding an empty capability list is a guarantee, not an oversight.
// Anything added to it stops being general chat and becomes an assistant that sometimes
// lies about this installation.
//
// EXPORTS
// vv_ai_profiles() the whole table
+9
View File
@@ -64,6 +64,15 @@
// vv_ai_route() action → route for this node
// vv_ai_rpc() forward one action to the owner, return its response body
//
// CONFIGURATION
// host*.conf
// <HOST>_SSH_KEY private key this node authenticates to the owner with. Absent or not a
// file means no remote route exists, and vv_ai_route() says so rather than
// attempting a hop that cannot succeed.
// <OWNER> the owner's hostname, looked up by the id vv_ai_owner_host() returns —
// resolved to an address through vv_resolve_tailscale_ip(), never used as
// a hostname directly, because MagicDNS does not resolve across the tailnets.
//
// DEPENDS ON
// include/config.php vv_ai_owner_host(), vv_ai_is_owner(), vv_resolve_tailscale_ip()
// Tools/ai_rpc.php the far side — reached at the WebGUI symlink path
+25
View File
@@ -43,6 +43,31 @@
//
// Redacted before it is logged, like every other question in this subsystem.
//
// EXPORTS
// Settings vv_ai_web_enabled(), vv_ai_web_provider(), vv_ai_web_results_max(),
// vv_ai_web_timeout(), vv_ai_web_searx_url(), vv_ai_web_degoog_url(),
// vv_ai_web_api_key(), vv_ai_web_ready()
// — every one reads conf; none of them reaches the network.
// Providers vv_ai_web_degoog(), vv_ai_web_searxng(), vv_ai_web_brave(), vv_ai_web_tavily()
// — one per backend, each returning the same normalised shape.
// Transport vv_ai_web_http(), vv_ai_web_normalise()
// Entry vv_ai_web_search(), vv_ai_web_context()
// — what the rest of the subsystem calls. Everything above is reachable but only
// these two are meant to be: search() picks the provider, context() formats for
// the prompt.
//
// CONFIGURATION
// master.conf
// AI_WEB_SEARCH_ENABLED master switch; anything but "true" disables the whole subsystem
// AI_WEB_SEARCH_PROVIDER which backend vv_ai_web_search() dispatches to
// AI_WEB_SEARCH_RESULTS result cap handed to the provider
// AI_WEB_SEARCH_TIMEOUT per-request timeout, in seconds
//
// host*.conf — per host, because the endpoint and the key are this machine's
// <HOST>_SEARXNG_URL self-hosted SearXNG base URL
// <HOST>_DEGOOG_URL self-hosted degoogle/Whoogle base URL
// <HOST>_WEB_SEARCH_API_KEY Brave or Tavily key, whichever provider is selected
//
// DEPENDS ON
// include/ai.php vv_ai_redact(), vv_conf_vars()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
+4 -1
View File
@@ -544,7 +544,10 @@ function vv_network_stats(): array {
}
// Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
$tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');
// Bounded like every other tailscale lookup in the tree. vv_network_stats() is served by
// api/monitor_fast.php, which the Monitor page polls once a second; an unbounded call here
// makes a slow tailscale silently drop that live cadence to whatever the CLI takes.
$tsIp = trim(shell_exec('timeout 2 tailscale ip -4 2>/dev/null | head -1') ?: '');
return [
'available' => true,
+50
View File
@@ -160,6 +160,56 @@ define('LOG_DIR', '/var/log/varaverk');
define('CUSTOM_SCRIPTS_DIR', $_vv_cfg['CUSTOM_SCRIPTS_DIR'] ?? '/boot/config/plugins/user.scripts/Varaverk/Scripts');
unset($_vv_cfg);
// ── Recording a failure this layer chose to survive ───────────────────────────────────────────
// The bash half writes a log for every run. The PHP half had nowhere to write at all — no
// error_log() call in 107 files — so an endpoint that caught an exception and carried on left no
// trace anywhere. Five directory walks do exactly that, and an unreadable directory renders as a
// legitimately empty result: the scheduler's script library shows "no scripts" whether you have
// none or the tree could not be read.
//
// This is the smallest thing that fixes it. It does not change any response contract; it records
// what was swallowed so the operator can find out why a page went empty.
//
// It lives in config.php because that is the one file every layer reaches — api/, include/ and
// pages/ all load it, several of them without ever loading common.php.
define('VV_PHP_LOG', LOG_DIR . '/php.log');
define('VV_PHP_LOG_MAX', 262144); // 256KB. /var/log is tmpfs on Unraid — this is RAM, so a
// repeating fault must not be able to grow without bound.
/**
* Record a failure that was caught and survived. Never throws, never echoes, never affects the
* response — a logger that can break a request is worse than no logger.
*/
function vv_log_error(string $where, string $message): void {
try {
if (!is_dir(LOG_DIR)) return;
// Timestamps are local, matching every bash log — see "The clock this layer runs on".
$line = date('Y-m-d H:i:s') . ' ' . $where . ' '
. preg_replace('/\s+/', ' ', trim($message)) . "\n";
$size = @filesize(VV_PHP_LOG);
if ($size !== false && $size > VV_PHP_LOG_MAX) {
// Drop the older half rather than the whole file, so a fault that repeats every poll
// still leaves recent context instead of a log that empties itself at random.
$keep = @file_get_contents(VV_PHP_LOG, false, null, (int)(VV_PHP_LOG_MAX / 2));
if ($keep !== false) {
// Resume on a line boundary — slicing at a byte offset lands mid-line, and a
// half-written first entry is worse than one fewer entry.
$nl = strpos($keep, "\n");
$keep = $nl === false ? '' : substr($keep, $nl + 1);
@file_put_contents(VV_PHP_LOG, $keep, LOCK_EX);
}
}
$isNew = !file_exists(VV_PHP_LOG);
@file_put_contents(VV_PHP_LOG, $line, FILE_APPEND | LOCK_EX);
// Do not inherit the ambient umask. php-fpm's is loose enough to create this 0666, and a
// world-writable file under /var/log is the kind of small thing that is never noticed.
if ($isNew) @chmod(VV_PHP_LOG, 0644);
} catch (Throwable $_) {
return; // Deliberately silent: this is the last place an error should propagate from.
}
}
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
// ── The two install layouts ───────────────────────────────────────────────────
+4 -4
View File
@@ -1,8 +1,4 @@
<?php
require_once __DIR__ . '/common.php';
require_once __DIR__ . '/fallback.php'; // vv_fb_proc(), vv_fb_dryrun_state() — daemon liveness
require_once __DIR__ . '/partnership.php'; // vv_pt_peer_match() — tailnet name vs conf hostname
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Monitor-page roll-ups that are not raw system metrics: partner reachability, fallback
@@ -57,6 +53,10 @@ require_once __DIR__ . '/partnership.php'; // vv_pt_peer_match() — tailnet n
// DATA_DIR container_restart_history.db
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/common.php';
require_once __DIR__ . '/fallback.php'; // vv_fb_proc(), vv_fb_dryrun_state() — daemon liveness
require_once __DIR__ . '/partnership.php'; // vv_pt_peer_match() — tailnet name vs conf hostname
function vv_partner_state(): array {
$vars = vv_conf_vars();
$myHostId = strtoupper(vv_detect_host());
+53 -6
View File
@@ -13,9 +13,14 @@
// Powers of two, all the way down.
// Column counts are 8, 4, 2, 1 and every span is 1, 2, 4 or 8. That pairing is what makes
// the board tile with no holes at every width without a single hand-placed card: 32 span
// units divide into 8 columns as 4 rows, into 4 as 8 rows, into 2 as 16, into 1 as 32.
// A rung of 6 or 10 columns does not divide a span of 4, which is why the previous ladder
// needed a per-breakpoint override for every wide card and still left holes.
// units divide into 8 columns as 4 rows and into 4 as 8. A rung of 6 or 10 columns does
// not divide a span of 4, which is why the previous ladder needed a per-breakpoint
// override for every wide card and still left holes.
//
// Below four columns the arithmetic stops being a division. Spans clamp to the column
// count, so a span-4 card contributes 2 at two columns and 1 at one, and the board is 13
// rows and then 18 rather than the 16 and 32 a naive division predicts. It still tiles
// without holes, for a different reason: a clamped card fills its row outright.
//
// Breakpoints are arithmetic, not taste.
// A column count is viable exactly when the cards still fit:
@@ -44,6 +49,23 @@
// The declaration and the markup are cross-checked at runtime. vv_mon_board_js() ships the
// declared ids to the page, which compares them against the cards actually present and flags
// either direction — a card in the markup nobody declared, or a declaration with no card.
//
// EXPORTS
// Declaration vv_mon_board(), vv_mon_board_cards(), vv_mon_board_total()
// — the one array every other function here reads. Change the layout by editing
// that declaration, never by editing what follows.
// Geometry vv_mon_rung_min(), vv_mon_rung_rows(), vv_mon_rung_rowdiv(),
// vv_mon_rung_overflows(), vv_mon_absent_class()
// — derive the column ladder and each card's span from the declaration.
// vv_mon_rung_overflows() is the one that asks whether a rung is already taller
// than one screen, which is when the row-height cap stops helping and starts
// clipping.
// Emitters vv_mon_board_css(), vv_mon_absorb_css(), vv_mon_autorows_css(),
// vv_mon_board_js()
// — vv_mon_autorows_css() is the hatch for an overflowing rung: rows sized to
// content, and the overflow clamps lifted off the cards so they can use it.
// Validation vv_mon_board_check()
// — the only one that reports rather than renders; see OPERATIONAL SAFEGUARDS.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// The narrowest a card may be drawn. Every breakpoint is a function of this number — see
@@ -63,9 +85,13 @@ const VV_MON_PAD = 20; // horizontal padding the Unraid page wrapper takes off
// The column ladder, widest first.
const VV_MON_RUNGS = [8, 4, 2, 1];
// Cap on how many rows may share one screen height. The row-height cap divides the viewport by
// this, so a card can never grow past a quarter of the screen and push the rest off it. Rungs
// with fewer rows than this divide by their own row count instead and fill the screen.
// Cap on how many rows may share one screen height — and the line between two behaviours, not one
// rule. A rung with no more rows than this fits a screen: the row-height cap divides the viewport
// by the rung's own row count, or by this when the rung has more, so the board fills the screen
// and no card grows past its share. A rung with MORE rows than this cannot fit however its rows
// are sized, so the cap is dropped there and rows size to content instead — see
// vv_mon_rung_overflows(). Capping a board that scrolls regardless buys nothing and clips every
// card to pay for it. This board is 4 rows at eight columns and 8 at four, so both cases are live.
const VV_MON_ROWS_PER_SCREEN = 4;
// ── The board ─────────────────────────────────────────────────────────────────────────────────
@@ -153,6 +179,25 @@ function vv_mon_rung_rowdiv(int $cols): int {
return min(vv_mon_rung_rows($cols), VV_MON_ROWS_PER_SCREEN);
}
// Whether this rung is already taller than one screen. The row-height cap means "no card taller
// than 1/VV_MON_ROWS_PER_SCREEN of the screen", which is only worth paying for on a rung that
// fits a screen — there it is what keeps the board from scrolling. On a rung with more rows than
// the cap the page scrolls no matter what, so the cap buys nothing and only cuts each card's
// content off. At four columns this board is eight rows: an 11" tablet in landscape was being
// given 126px cards on a board two screens tall.
function vv_mon_rung_overflows(int $cols): bool {
return vv_mon_rung_rows($cols) > VV_MON_ROWS_PER_SCREEN;
}
// Rows sized by content instead of by the cap, for a rung that overflows the screen anyway.
// Mirrors the phone hatch in css/varaverk.css: the cards have to give up overflow:hidden too, or
// they keep clipping at a height nothing is constraining any more.
function vv_mon_autorows_css(): string {
return '#vv-monitor{grid-auto-rows:auto;}'
. '#vv-monitor .vv-card{overflow:visible;}'
. '#vv-monitor .vv-card>div{overflow-y:visible;min-height:auto;}';
}
// The class the page puts on #vv-monitor when a card is not rendered, so the generated absorb
// rules can fire. Derived from the id on both sides — PHP writes the rule, JS writes the class —
// so the two can never drift apart by a typo.
@@ -205,6 +250,7 @@ function vv_mon_board_css(): string {
$out = '';
$out .= "#vv-monitor{--vv-cols:$top;--vv-rowdiv:" . vv_mon_rung_rowdiv($top) . ";}\n";
if (vv_mon_rung_overflows($top)) $out .= vv_mon_autorows_css() . "\n";
$order = 0;
foreach ($cards as $card) {
@@ -234,6 +280,7 @@ function vv_mon_board_css(): string {
if ($wide) $out .= implode(',', $wide) . "{--vv-sp:$cols;}";
}
if (vv_mon_rung_overflows($cols)) $out .= vv_mon_autorows_css();
$out .= vv_mon_absorb_css($cols);
$out .= "}\n";
}
+24 -9
View File
@@ -52,6 +52,16 @@
// vv_nc_send() store + deliver to the channel's other members
// vv_nc_delete_local() forget one message on this machine
// vv_nc_flush_spool() retry undelivered
//
// CONFIGURATION
// host*.conf
// <HOST>_SSH_KEY private key used to reach the channel's other members. Missing or not a
// file means delivery is skipped and the message is spooled — stored here
// and retried later, never reported as failed.
// <HOST> each member's hostname, looked up by the id in the channel definition.
//
// The store's own paths are defines, not conf: VV_NC_DIR under DATA_DIR/db, its .spool
// subdirectory, and VV_NC_KEEP — the per-channel message retention, fixed at 300.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/config.php';
@@ -724,16 +734,21 @@ function vvNcInit(prefix, meshDefault) {
}
// Restore the expanded state before the first paint, so the card does not visibly resize.
try {
if (localStorage.getItem('vvNcBig:' + prefix) === '1') {
_vvNcBig = true;
_vvNcApplyBig();
// Bring the assistant along, so both halves start the session agreeing.
if (aiBtn && !aiBtn.classList.contains('vv-ai-grow-on')) {
_vvNcSyncing = true; aiBtn.click(); _vvNcSyncing = false;
}
//
// Only the storage read is guarded. The try used to wrap _vvNcApplyBig() and aiBtn.click()
// too, which meant a TypeError in either — or in any handler aiBtn.click() reaches — was
// caught here and read as "localStorage is blocked". The layout would silently fail to
// restore, with nothing in the console and no way to tell the two causes apart.
let _wantBig = false;
try { _wantBig = localStorage.getItem('vvNcBig:' + prefix) === '1'; } catch (_) {}
if (_wantBig) {
_vvNcBig = true;
_vvNcApplyBig();
// Bring the assistant along, so both halves start the session agreeing.
if (aiBtn && !aiBtn.classList.contains('vv-ai-grow-on')) {
_vvNcSyncing = true; aiBtn.click(); _vvNcSyncing = false;
}
} catch (_) {}
}
vvNcMode(want);
vvNcChans();
_vvNcSchedule();
+1 -1
View File
@@ -468,7 +468,7 @@ function vv_script_library(): array {
if (in_array($rel, $orchIds) || isset($confMap[$rel]) || isset($cardIds[$rel]) || isset($schedule[$rel])) continue;
$library[] = ['id' => $rel, 'label' => vv_pretty_label(basename($rel, '.sh'))];
}
} catch (Exception $e) {}
} catch (Exception $e) { vv_log_error('include/scheduler.php', 'script library walk failed: ' . $e->getMessage()); }
usort($library, fn($a, $b) => strcmp($a['id'], $b['id']));
return $library;
}
+1
View File
@@ -1079,6 +1079,7 @@ function vvPollMonitor(live) {
<span style="color:#444;">Running</span> <span style="color:#888;">${_runningCtrs} ctr${_runningCtrs !== 1 ? 's' : ''}${_runningVMs > 0 ? ` · ${_runningVMs} VM` : ''}</span>
<span style="color:#444;">Version</span> <span style="color:#3a3a3a;">${ver}</span>
</div>
<!-- Varaverk's own figures, ruled off from the host's. Same grid so the labels line up,
separate block so it reads as a different subject rather than more of the same. -->
<div style="border-top:1px solid #1e1e1e;margin-top:8px;padding-top:7px;">
@@ -10,6 +10,22 @@ a minute, so polling faster could not make it newer — it only decides how soon
**Cards you do not have do not appear.** No GPU, no UPS, no VMs, no partner — the card is absent
rather than showing zeros. An empty card would be a permanent reminder of nothing.
**The board re-flows on a narrow screen.** On a tablet or a phone the cards regroup into fewer,
wider columns rather than shrinking in place. Nothing is hidden and nothing is dropped — the same
cards are there in the same order, cut into different rows.
Below the full-width layout the page scrolls as a whole instead of each card scrolling inside
itself. A board that is taller than the screen has to scroll somewhere, and scrolling the page
once is better than being handed a screen of cards that are each too short to read.
**Cards in the same row share a height.** They line up along the bottom, so a card with little to
say carries empty space under it. That space means its neighbour is the taller card — not that a
reading is missing.
The System card is the one exception. On a narrow screen it stops after Version and the Varaverk
figures below scroll, so that the host itself — hostname, clock, array state, uptime — is what
you get without scrolling anything.
---
## Reference — System, power, CPU, memory, network
+1 -1
View File
@@ -126,7 +126,7 @@ try {
if (in_array($_parts[0], $_repoSkip)) continue;
$_repoFiles[] = $_rel;
}
} catch (Exception $_re) {}
} catch (Exception $_re) { vv_log_error('pages/scheduler.php', 'repo file walk failed: ' . $_re->getMessage()); }
sort($_repoFiles);
// Docs tree: README and Manual with their per-module children
+63
View File
@@ -37,6 +37,12 @@
// Step 2: auto-populate + guide + checklist.
// master.conf pull (for partner servers) lives in the checklist, not here.
// The assistant is a bonus on this page, never a dependency: vv_ai_ui_on() is false on a node
// with AI off or no model reachable, which is the normal state of the fresh install this wizard
// exists to serve. The card simply is not rendered and setup proceeds exactly as before.
require_once dirname(__DIR__) . '/include/ai_chat.php';
if (vv_ai_ui_on()) vv_ai_chat_assets();
$detectedHostname = vv_get_hostname();
// ── Identity already on disk? ────────────────────────────────────────────────────────────────
@@ -313,6 +319,21 @@ hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; }
<div id="vv-onboard-panel"></div>
<div id="vv-done-banner"></div>
<?php if (vv_ai_ui_on()): ?>
<hr class="vv-hr">
<div id="vv-su-ai-card">
<?php vv_ai_chat_markup('vv-su-ai', [
'profile' => 'varaverk',
'compact' => true,
'title' => 'Setup assistant',
'height' => '260px',
'tall' => '460px',
'empty' => 'Stuck on a step? Ask what it wants and why — "what is the API key for", '
. '"why does SSH need the other host", "what does populate actually do".',
]); ?>
</div>
<?php endif; ?>
<div style="margin-top:18px;text-align:right;">
<a href="#" id="vv-exit-link" onclick="vvGoNext(event)"
style="font-size:11px;color:#3a3a3a;text-decoration:none;">Skip →</a>
@@ -443,6 +464,7 @@ function vvShowStep2(redirect, apiKey) {
_vvRedirect = redirect || '?tab=scheduler';
document.getElementById('vv-step1').style.display = 'none';
document.getElementById('vv-step2').style.display = 'block';
vvSetupAiInit(); // card is visible now, so it can measure itself correctly
if (apiKey && apiKey.ok) {
const btn = document.getElementById('vv-key-btn');
const status = document.getElementById('vv-key-status');
@@ -676,6 +698,47 @@ function vvStartPhase2Watch(sinceMs) {
_vvPartnerPoll = setInterval(check, 6000);
}
// ── Setup assistant ───────────────────────────────────────────────────────────
// Scoped to the step the operator is actually stuck on, so "what does this want" resolves without
// them naming it. The worker turns a non-empty scope into a line saying what is open in the
// WebGUI; the varaverk profile holds no scoped_log capability, so a checklist id never triggers a
// log lookup and cannot produce a "log missing" note for something that was never a script.
//
// Re-read at send time, not captured: the checklist re-polls while the page is open and the step
// they are on can change between opening the composer and pressing Ask.
//
// First not-ok, not-deferred item — deferred means "I have decided to skip this", which is not
// where they are stuck. Falls back to the bare page when the list is clean or has not loaded.
function vvSetupScope() {
const d = _vvLastChecklist;
if (!d || !Array.isArray(d.items)) return 'Setup';
const stuck = d.items.find(i => !i.ok && !i.deferred);
return stuck && stuck.id ? 'Setup/' + stuck.id : 'Setup';
}
// Mounted when Step 2 is revealed, not at parse time. This is the only page where the card starts
// inside a display:none block, and a chat that measured itself while hidden would come up wrong
// with nothing to correct it — the component carries no resize observer. Idempotent because
// vvShowStep2 is reachable more than once.
let _vvSetupAiUp = false;
function vvSetupAiInit() {
if (_vvSetupAiUp) return;
if (typeof VvAiChat !== 'function') return;
if (!document.getElementById('vv-su-ai-chat')) return; // AI off — card was never rendered
_vvSetupAiUp = true;
VvAiChat({
prefix: 'vv-su-ai',
profile: 'varaverk',
// Pinned, for the same reason the Partnership card pins it: resuming whatever thread was last
// touched anywhere in the UI could land a first-run operator mid-way through someone else's
// Scheduler conversation, on a card with no picker to get back from.
resumeProfile: 'varaverk',
scope: vvSetupScope,
scopeLabel: 'Setup',
empty: 'Stuck on a step? Ask what it wants and why.',
});
}
function vvLoadChecklist() {
fetch('/plugins/varaverk/api/checklist.php?_=' + Date.now())
.then(r => r.json()).then(d => {
+19
View File
@@ -35,6 +35,25 @@
# progress in the UI, and lets it be re-run or cancelled without touching onboard.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# One share at a time, in DAILY_SYNC_SHARES order, each handed to Rsync/rsync.sh --seed. The
# transfer itself, its bandwidth cap and its resume behaviour are all rsync.sh's — this script
# decides what to seed and in what order, never how.
#
# Sequential on purpose. The cap that matters is DEFAULT_RSYNC_OPTS' --bwlimit, which is a limit
# per rsync rather than per host; running shares in parallel would multiply it by the number of
# shares and saturate the link the rest of the ecosystem is still using.
#
# Refuses before it starts rather than partway. RSYNC_ENABLED must be true and DAILY_SYNC_SHARES
# must be non-empty, both checked up front — a multi-week transfer is the wrong place to
# discover that the global gate was off.
#
# Nothing waits on this. Phase 3 runs after the partnership is already established, so a seed
# that is still copying weeks later blocks no flag, no status card and no job record.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
+15 -9
View File
@@ -143,8 +143,10 @@ if [[ "$SHOW_STATUS" == true ]]; then
echo "━━━ System Logs ━━━"
for f in "${LOG_FILES[@]}"; do
if [[ -f "$f" ]]; then
size=$(du -sh "$f" 2>/dev/null | cut -f1)
size_mb=$(du -sm "$f" 2>/dev/null | cut -f1)
# One traversal, then formatted — this used to run du twice over the same path,
# once for the display string and once for the comparison.
size_mb=$(dir_size_mb "$f") || size_mb=0
size=$(format_mb "$size_mb")
threshold="${LOG_MIN_SIZE_MB:-10}"
if [[ "${size_mb:-0}" -ge "$threshold" ]]; then
echo " $ICON_WARN $f$size (above ${threshold}MB threshold — would clear)"
@@ -161,7 +163,7 @@ if [[ "$SHOW_STATUS" == true ]]; then
if [[ -d /var/lib/docker/containers ]]; then
find /var/lib/docker/containers/ -name "*-json.log" 2>/dev/null | \
while IFS= read -r logfile; do
size_mb=$(du -sm "$logfile" 2>/dev/null | cut -f1)
size_mb=$(dir_size_mb "$logfile") || size_mb=0
container_id=$(basename "$(dirname "$logfile")" | cut -c1-12)
container_name=$(docker inspect --format '{{.Name}}' "$container_id" \
2>/dev/null | tr -d '/' || echo "$container_id")
@@ -204,9 +206,12 @@ for logfile in "${LOG_FILES[@]}"; do
continue
fi
size_bytes=$(stat -c%s "$logfile" 2>/dev/null || echo 0)
size_mb=$(( size_bytes / 1048576 ))
size_h=$(du -sh "$logfile" 2>/dev/null | cut -f1)
# Same basis as the dry-run preview above. This measured stat -c%s (apparent size) while the
# preview measured du (allocated blocks), so a file sitting on LOG_MIN_SIZE_MB could be shown
# as under the threshold and then cleared, or the reverse. A dry run that disagrees with the
# real run about what it will touch is worse than no dry run.
size_mb=$(dir_size_mb "$logfile") || size_mb=0
size_h=$(format_mb "$size_mb")
threshold="${LOG_MIN_SIZE_MB:-10}"
if [[ "$size_mb" -lt "$threshold" ]]; then
@@ -239,9 +244,10 @@ else
while IFS= read -r logfile; do
[[ -z "$logfile" ]] && continue
size_bytes=$(stat -c%s "$logfile" 2>/dev/null || echo 0)
size_mb=$(( size_bytes / 1048576 ))
size_h=$(du -sh "$logfile" 2>/dev/null | cut -f1)
# du basis, matching the "top 10 by size" listing above — that ranked and previewed on
# du while this cleared on stat, so the two could disagree about the same file.
size_mb=$(dir_size_mb "$logfile") || size_mb=0
size_h=$(format_mb "$size_mb")
threshold="${LOG_DOCKER_MAX_MB:-100}"
# Get container name for display
+24 -7
View File
@@ -83,7 +83,8 @@ done
# and handled as soft checks — see OPERATIONAL MODEL.
spec_for() {
case "$1" in
sh|tools) echo "PURPOSE|OPERATIONAL MODEL|DESIGN PRINCIPLES|OPERATIONAL SAFEGUARDS|RUNTIME MODES" ;;
sh) echo "PURPOSE|OPERATIONAL MODEL|DESIGN PRINCIPLES|OPERATIONAL SAFEGUARDS|RUNTIME MODES" ;;
tools) echo "PURPOSE|OPERATIONAL MODEL|DESIGN PRINCIPLES|OPERATIONAL SAFEGUARDS|RUNTIME MODES/ARGUMENTS" ;;
api) echo "PURPOSE|OPERATIONAL MODEL|DESIGN PRINCIPLES|OPERATIONAL SAFEGUARDS|REQUEST|RESPONSE|DEPENDS ON" ;;
include) echo "PURPOSE|DESIGN PRINCIPLES|OPERATIONAL SAFEGUARDS|EXPORTS" ;;
pages) echo "PURPOSE|DESIGN PRINCIPLES|OPERATIONAL SAFEGUARDS|RENDERS|DEPENDS ON" ;;
@@ -91,6 +92,13 @@ spec_for() {
}
role_for() {
# A PHP file that refuses to run outside the CLI is a script, wherever it is filed.
# api/docker_pull_worker.php lives beside the endpoints because docker_action.php spawns it,
# but it takes argv and never a request — judging it on REQUEST and RESPONSE would mean
# inventing a contract it does not have.
if [[ "$1" == *.php ]] && grep -q "PHP_SAPI !== 'cli'" "$1" 2>/dev/null; then
echo "tools"; return
fi
case "$1" in
*.sh) echo "sh" ;;
*/Plugin/*/Tools/*.php) echo "tools" ;;
@@ -130,12 +138,21 @@ while IFS= read -r f; do
present=()
missing=()
for s in "${want[@]}"; do
n=$(grep -c "^$m $s\$" "$f")
case "$n" in
0) missing+=("$s") ;;
1) present+=("$s") ;;
*) present+=("$s"); problems+=("duplicate section: $s appears $n times") ;;
esac
# A spec entry may name alternatives with "/". RUNTIME MODES/ARGUMENTS is the only one:
# a worker that is spawned rather than run has no modes an operator can choose, and both
# spawned workers in the tree document ARGUMENTS instead. Demanding RUNTIME MODES there
# would mean writing an invocation nobody performs.
found=""
IFS='/' read -ra alts <<< "$s"
for a in "${alts[@]}"; do
n=$(grep -c "^$m $a\$" "$f")
if [[ "$n" -gt 1 ]]; then
present+=("$a"); problems+=("duplicate section: $a appears $n times"); found="$a"; break
elif [[ "$n" -eq 1 ]]; then
present+=("$a"); found="$a"; break
fi
done
[[ -z "$found" ]] && missing+=("${s//\// or }")
done
if [[ ${#missing[@]} -gt 0 ]]; then
problems+=("missing: $(IFS=', '; echo "${missing[*]}")")
+19 -1
View File
@@ -23,6 +23,7 @@
# rsync would then stop nothing before copying a live database. They are excluded by name and
# must stay excluded.
#
# OPERATIONAL SAFEGUARDS
# A docker that cannot be read is not a docker with nothing in it.
# If `docker ps -a` fails, times out, or returns nothing on a host that is meant to run
# containers, EVERY name looks missing and one run would strike the entire configuration.
@@ -34,7 +35,24 @@
# verified by sourcing the result in a subshell and confirming the key still parses as an
# array with exactly one fewer element.
#
# USAGE
# Each removal is its own verified rewrite.
# Several names can reach the limit in one run, and each is removed and re-verified
# independently rather than batched into a single edit. A rewrite that fails verification
# therefore costs that one entry, not every entry the run intended to prune.
#
# --dry-run records no strike. A dry run that advanced the counter would eventually prune
# through repetition alone, which is the opposite of what it is for.
#
# CONFIGURATION
# master.conf
# CONF_PRUNE_STRIKE_LIMIT consecutive runs a name must be missing before it is removed.
# Seeing the container again resets its strike to zero immediately,
# so a rebuild costs one strike at most.
#
# The keys this may prune are an explicit allow list in the script, deliberately not a conf
# value — see DESIGN PRINCIPLES for why a pattern is the wrong shape here.
#
# RUNTIME MODES
# conf_container_prune.sh strike, and prune anything at the limit
# conf_container_prune.sh --dry-run report what would be struck and pruned, write nothing
# conf_container_prune.sh --status show current strikes and stop
+19 -6
View File
@@ -144,13 +144,26 @@ if [[ ! -d "$OUTPUT_DIR" ]]; then
fi
# Space check — conservative: appdata × 1.1
APPDATA_SIZE_KB=$(du -sk "$APPDATA_PATH" 2>/dev/null | cut -f1)
OUTPUT_FREE_KB=$(df "$OUTPUT_DIR" --output=avail 2>/dev/null | tail -1 | tr -d ' ')
REQUIRED_KB=$(( APPDATA_SIZE_KB * 11 / 10 ))
APPDATA_SIZE_H=$(du -sh "$APPDATA_PATH" 2>/dev/null | cut -f1)
OUTPUT_FREE_H=$(df -h "$OUTPUT_DIR" --output=avail 2>/dev/null | tail -1 | tr -d ' ')
#
# One traversal of the appdata tree, not two. This measured it twice — once with du -sk for the
# arithmetic and again with du -sh for the message — and walked $OUTPUT_DIR twice as well. On a
# container's appdata that is the expensive call in this script, paid twice to print a string.
# An unreadable size is not a small size. Defaulting either of these to 0 makes the check below
# pass — a zero requirement clears any free space, and the export then runs toward a disk that
# was never measured. Unknown stops here instead.
if ! APPDATA_SIZE_MB=$(dir_size_mb "$APPDATA_PATH"); then
error "Could not measure $APPDATA_PATH — refusing to export without a space check"
exit 1
fi
if ! OUTPUT_FREE_MB=$(disk_free_mb "$OUTPUT_DIR"); then
error "Could not read free space on $OUTPUT_DIR — refusing to export without a space check"
exit 1
fi
REQUIRED_MB=$(( APPDATA_SIZE_MB * 11 / 10 ))
APPDATA_SIZE_H=$(format_mb "$APPDATA_SIZE_MB")
OUTPUT_FREE_H=$(format_mb "$OUTPUT_FREE_MB")
if [[ "$OUTPUT_FREE_KB" -lt "$REQUIRED_KB" ]]; then
if [[ "$OUTPUT_FREE_MB" -lt "$REQUIRED_MB" ]]; then
error "Insufficient space in $OUTPUT_DIR"
error "Estimated need: ~${APPDATA_SIZE_H} (×1.1 conservative) — available: ${OUTPUT_FREE_H}"
exit 1
Executable → Regular
+10 -10
View File
@@ -20,16 +20,6 @@
# know all stopped containers are safe to delete.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Safe Default, Explicit Escalation
# The default mode (dangling only) is always safe — running containers are
# never affected. The --all mode requires deliberate opt-in and carries an
# explicit caution in the description, because it removes stopped containers
# that may be intentionally paused.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
@@ -47,6 +37,16 @@
# Reclaimed space is reported for both modes.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Safe Default, Explicit Escalation
# The default mode (dangling only) is always safe — running containers are
# never affected. The --all mode requires deliberate opt-in and carries an
# explicit caution in the description, because it removes stopped containers
# that may be intentionally paused.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
+5 -3
View File
@@ -154,9 +154,11 @@ if [[ "$SHOW_STATUS" == true ]]; then
echo ""
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
USAGE=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $3}')
AVAIL=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $4}')
echo " $ICON_RAM Ramdisk: mounted — $USAGE used / $AVAIL available ✅"
if read -r _ USED_MB AVAIL_MB <<< "$(disk_df "$RAMDISK_PATH")" && [[ -n "$AVAIL_MB" ]]; then
echo " $ICON_RAM Ramdisk: mounted — $(format_mb "$USED_MB") used / $(format_mb "$AVAIL_MB") available ✅"
else
echo " $ICON_RAM Ramdisk: mounted — size unreadable"
fi
FILE_COUNT=$(find "$RAMDISK_PATH" -type f 2>/dev/null | wc -l)
echo " $ICON_RAM Active files on ramdisk: $FILE_COUNT"
else
+12 -6
View File
@@ -197,9 +197,13 @@ if [[ "$SHOW_STATUS" == true ]]; then
echo ""
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
USAGE=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $3}')
AVAIL=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $4}')
echo " $ICON_RAM Ramdisk: mounted — $USAGE used / $AVAIL available ✅"
# One df, not two — used and available come from the same read, so they cannot
# describe two different moments of a ramdisk that is actively being written to.
if read -r _ USED_MB AVAIL_MB <<< "$(disk_df "$RAMDISK_PATH")" && [[ -n "$AVAIL_MB" ]]; then
echo " $ICON_RAM Ramdisk: mounted — $(format_mb "$USED_MB") used / $(format_mb "$AVAIL_MB") available ✅"
else
echo " $ICON_RAM Ramdisk: mounted — size unreadable"
fi
else
echo " $ICON_RAM Ramdisk: NOT mounted"
fi
@@ -228,9 +232,11 @@ START=$(date +%s)
SETUP_SUCCESS=true
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
USAGE=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $3}')
AVAIL=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $4}')
log "Ramdisk already mounted — $USAGE used / $AVAIL available"
if read -r _ USED_MB AVAIL_MB <<< "$(disk_df "$RAMDISK_PATH")" && [[ -n "$AVAIL_MB" ]]; then
log "Ramdisk already mounted — $(format_mb "$USED_MB") used / $(format_mb "$AVAIL_MB") available"
else
log "Ramdisk already mounted — size unreadable"
fi
log "Skipping mount — verifying symlink and permissions"
else
if [[ "$DRY_RUN" == true ]]; then
+5 -3
View File
@@ -195,9 +195,11 @@ if [[ "$SHOW_STATUS" == true ]]; then
echo ""
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
USAGE=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $3}')
AVAIL=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $4}')
echo " $ICON_RAM Ramdisk: mounted — $USAGE used / $AVAIL available ✅"
if read -r _ USED_MB AVAIL_MB <<< "$(disk_df "$RAMDISK_PATH")" && [[ -n "$AVAIL_MB" ]]; then
echo " $ICON_RAM Ramdisk: mounted — $(format_mb "$USED_MB") used / $(format_mb "$AVAIL_MB") available ✅"
else
echo " $ICON_RAM Ramdisk: mounted — size unreadable"
fi
else
echo " $ICON_RAM Ramdisk: not mounted"
fi
+13 -3
View File
@@ -270,7 +270,17 @@ log "$ICON_SUCCESS Internet reachable"
# ==============================================================================================
if [[ -n "$DDNS_DOMAIN" ]] && [[ -n "$DDNS_CONTAINER" ]]; then
PUBLIC_IP=$(curl -sf --max-time 5 https://ifconfig.me 2>/dev/null | tr -d '[:space:]')
DNS_IP=$(dig +short "$DDNS_DOMAIN" @1.1.1.1 2>/dev/null | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
# dig's failure text names the resolver it could not reach (";; communications error to
# 1.1.1.1#53: timed out"), so scraping its output for an address yields the server, not the
# answer — a guaranteed mismatch that restarts DDNS over what is only a DNS timeout. Trust
# the exit status, and anchor the match so only a bare answer line counts. NXDOMAIN exits 0
# with no output and correctly falls through to the "could not resolve" branch below.
if DNS_ANSWER=$(dig +short "$DDNS_DOMAIN" @1.1.1.1 2>/dev/null); then
DNS_IP=$(printf '%s\n' "$DNS_ANSWER" \
| grep -Eox '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
else
DNS_IP=""
fi
if [[ -z "$PUBLIC_IP" ]]; then
warn "Could not determine public IP — skipping DDNS check"
@@ -285,7 +295,7 @@ if [[ -n "$DDNS_DOMAIN" ]] && [[ -n "$DDNS_CONTAINER" ]]; then
warn "DRY RUN — would restart $DDNS_CONTAINER"
else
warn "Restarting $DDNS_CONTAINER to trigger Cloudflare update..."
if docker restart "$DDNS_CONTAINER" >/dev/null 2>&1; then
if timeout "$DOCKER_TIMEOUT" docker restart "$DDNS_CONTAINER" >/dev/null 2>&1; then
warn "$DDNS_CONTAINER restarted ✅"
notify "DDNS mismatch on $(hostname) ($MY_ID) — $DDNS_DOMAIN was $DNS_IP, public is $PUBLIC_IP$DDNS_CONTAINER restarted" \
"Network Watchdog" "warning"
@@ -342,7 +352,7 @@ if [[ -n "$NPM_URL" ]]; then
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would restart NginxProxyManager"
else
if docker restart NginxProxyManager >/dev/null 2>&1; then
if timeout "$DOCKER_TIMEOUT" docker restart NginxProxyManager >/dev/null 2>&1; then
warn "NginxProxyManager restarted ✅"
set_strikes "npm" 0 "${NETWORK_WATCHDOG_NPM_STATE_FILE}"
notify "NPM proxy restarted on $(hostname) ($MY_ID) — $NPM_URL was unreachable for $NPM_STRIKES cycles" \
+1 -1
View File
@@ -1231,7 +1231,7 @@ CYCLE_START=$(date +%s)
TOTAL_WARNINGS=$(( T1_WARNINGS + T2_WARNINGS ))
CYCLE_END=$(date +%s)
CONTAINER_COUNT=$(docker ps -q 2>/dev/null | wc -l | tr -d ' ')
CONTAINER_COUNT=$(timeout "$DOCKER_TIMEOUT" docker ps -q 2>/dev/null | wc -l | tr -d ' ')
if [[ "$TOTAL_RESTARTS" -gt 0 || "$TOTAL_WARNINGS" -gt 0 ]]; then
echo "$ICON_WATCHDOG T1: $T1_RESTARTS restarts / $T1_WARNINGS warnings T2: $T2_RESTARTS restarts / $T2_WARNINGS warnings"
echo "$ICON_TIME Duration: $(format_duration $(( CYCLE_END - CYCLE_START ))) Containers: $CONTAINER_COUNT"
+5 -2
View File
@@ -210,9 +210,12 @@ _rw_trap_restart_stopped() {
[[ ${#_RW_TRAP_STOPPED[@]} -eq 0 ]] && return
for c in "${_RW_TRAP_STOPPED[@]}"; do
[[ -z "$c" ]] && continue
if docker inspect "$c" >/dev/null 2>&1; then
# Bounded, because this runs from the EXIT trap. An unbounded docker call here means a
# hung daemon stops the script exiting at all — it keeps its lock, and the containers
# this trap exists to bring back stay down.
if timeout "$DOCKER_TIMEOUT" docker inspect "$c" >/dev/null 2>&1; then
warn "Exit trap: restarting $c (stopped but state not persisted)"
docker start "$c" >/dev/null 2>&1 || warn " Failed to restart $c"
timeout "$DOCKER_TIMEOUT" docker start "$c" >/dev/null 2>&1 || warn " Failed to restart $c"
fi
done
}
+28 -3
View File
@@ -449,7 +449,11 @@ _trap_sys_reboot_restart() {
warn "Exit trap: restarting containers stopped before aborted reboot"
for c in "${_SYS_REBOOT_STOPPED[@]}"; do
[[ -z "$c" ]] && continue
docker inspect "$c" >/dev/null 2>&1 && docker start "$c" >/dev/null 2>&1 || true
# Bounded — this is the aborted-reboot recovery path, running from an EXIT trap. If the
# daemon is wedged (which is a reason a reboot was being attempted), an unbounded call
# here leaves every container it stopped down and the script never exits.
timeout "$DOCKER_TIMEOUT" docker inspect "$c" >/dev/null 2>&1 \
&& timeout "$DOCKER_TIMEOUT" docker start "$c" >/dev/null 2>&1 || true
done
}
@@ -529,9 +533,30 @@ do_reboot() {
warn "Stopping Docker containers..."
if is_docker_enabled && command -v docker >/dev/null 2>&1; then
mapfile -t _SYS_REBOOT_STOPPED < <(docker ps --format '{{.Names}}' 2>/dev/null)
mapfile -t _SYS_REBOOT_STOPPED < <(timeout "$DOCKER_TIMEOUT" docker ps --format '{{.Names}}' 2>/dev/null)
trap _trap_sys_reboot_restart EXIT
timeout 60 docker ps -q 2>/dev/null | xargs -r docker stop >/dev/null 2>&1
# One container at a time, bounded individually, named when it fails.
#
# This was `timeout 60 docker ps -q | xargs -r docker stop`, where the timeout bounded
# only the left of the pipe — the stops themselves were unbounded, so a wedged daemon
# hung the reboot path indefinitely, on the half that actually stops the containers.
#
# Bounding the batch instead would mean picking a number against the container count,
# and being wrong either kills containers mid-write or never returns. Per container that
# question disappears: DOCKER_TIMEOUT is three times docker's own 10s stop grace, and
# holds whether this host runs five containers or fifty.
#
# It also reuses the list already read above rather than asking docker a second time,
# and matches stop_local_containers() in common.sh — loop, report by name, never xargs.
for _c in "${_SYS_REBOOT_STOPPED[@]}"; do
[[ -z "$_c" ]] && continue
if timeout "$DOCKER_TIMEOUT" docker stop "$_c" >/dev/null 2>&1; then
log " $_c stopped"
else
warn " $_c did not stop within ${DOCKER_TIMEOUT}s — continuing to reboot"
fi
done
fi
warn "Stopping User Scripts..."
Executable → Regular
+171 -8
View File
@@ -396,6 +396,59 @@ kb_to_gb() {
awk "BEGIN {printf \"%.${decimals}f\", $kb / 1048576}"
}
# ── Disk probes ───────────────────────────────────────────────────────────────────────────────
# Everything below reports MEGABYTES, matching vv_df() in Plugin/unraid/include/common.php. The
# two languages answer the same question and must answer it in the same unit; a page and a script
# disagreeing about a threshold is the harder bug to see.
#
# These exist because the bash side had no disk helper at all — 46 call sites across 19 files
# each reached for df or du directly, in six different flag styles (df -BG, df -P, df -h, du -sh,
# du -sm, du -sk). That is not a preference, it is six chances for a unit mismatch.
#
# du measures ALLOCATED BLOCKS; stat -c%s measures APPARENT SIZE. They differ on sparse files and
# by up to a block elsewhere. dir_size_mb() is the du basis. If a caller needs apparent size it
# wants stat, and should say so — not quietly pick the other one.
# Filesystem capacity for the path's mount, in MB.
# Usage: disk_df /mnt/user → "size_mb used_mb free_mb" (empty if it cannot be read)
disk_df() {
local path="${1:-/}"
[[ -e "$path" ]] || return 1
df -BM --output=size,used,avail "$path" 2>/dev/null | tail -1 | tr -dc '0-9 \n' | awk 'NF==3'
}
# Free megabytes on the filesystem holding a path. Empty and non-zero if undeterminable —
# never 0, which a caller would read as "full" and act on.
# Usage: free=$(disk_free_mb /mnt/cache) || warn "could not read free space"
disk_free_mb() {
local out
out=$(disk_df "${1:-/}") || return 1
[[ -z "$out" ]] && return 1
awk '{print $3}' <<< "$out"
}
# Size of a file or directory in MB, du basis. Empty and non-zero if unreadable.
# Usage: mb=$(dir_size_mb "$path") || mb=0
dir_size_mb() {
local path="$1" out
[[ -e "$path" ]] || return 1
out=$(du -sm "$path" 2>/dev/null | cut -f1)
[[ -z "$out" ]] && return 1
echo "$out"
}
# Human-readable string from a megabyte count — so a caller that already has MB never runs a
# second traversal just to print it. format_bytes() is the byte-input equivalent.
# Usage: format_mb 2048 → 2.0GB
format_mb() {
local mb=${1:-0}
if (( mb >= 1024 )); then
awk "BEGIN {printf \"%.1fGB\", $mb / 1024}"
else
echo "${mb}MB"
fi
}
# ==============================================================================================
# ── ARG PARSER ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
@@ -910,8 +963,12 @@ resolve_remote_ip() {
local attempts=3
for ((i=1; i<=attempts; i++)); do
# Direct lookup — works when MagicDNS short-name resolution is active
REMOTE_SERVER=$(tailscale ip -4 "$ts_name" 2>/dev/null)
# Direct lookup — works when MagicDNS short-name resolution is active.
# Bounded, because it is not active here: the two hosts sit on separate tailnets, so
# this call cannot succeed and blocks ~5s before giving up, while the status fallback
# below answers in milliseconds. resolve_tailscale_ip() was bounded for this reason;
# this function has the same lookup and was missed.
REMOTE_SERVER=$(timeout 2 tailscale ip -4 "$ts_name" 2>/dev/null)
# Fallback — parse tailscale status for FQDN entries (e.g. hostname.tailXXXX.ts.net)
if [[ -z "$REMOTE_SERVER" ]]; then
@@ -2216,8 +2273,10 @@ acquire_rsync_lock() {
# ==============================================================================================
# Generic get/set for flat "key<sep>value" state files (one entry per line) — the pattern
# every watchdog's strike-tracking and state-file logic was independently reimplementing.
# grep -v + tempfile-swap on write, not sed -i in place — avoids sed treating a key containing
# regex metacharacters (container names, etc.) as part of the substitution pattern.
# Keys are matched as literal prefixes via awk substr(), never as regexes. A key is often a
# media file path, and a release tag like [Bluray-1080p] is a valid-looking bracket expression
# holding the reversed range 1-0 — grep -E rejects it, exits 2, and the tempfile-swap then
# commits an empty file, silently wiping every other entry.
#
# Usage: wd_state_get "$key" "$file" [sep=:]
# wd_state_set "$key" "$value" "$file" [sep=:]
@@ -2227,14 +2286,24 @@ acquire_rsync_lock() {
# keep their own thin same-named wrapper around these rather than changing call sites.
wd_state_get() {
local key="$1" file="$2" sep="${3:-:}"
grep -E "^${key}${sep}" "$file" 2>/dev/null | cut -d"$sep" -f2-
[[ -f "$file" ]] || return 0
awk -v pfx="${key}${sep}" \
'substr($0, 1, length(pfx)) == pfx { print substr($0, length(pfx) + 1); exit }' \
"$file" 2>/dev/null
}
# Returns 1 without touching the state file if the rewrite fails, so a read error costs the
# caller one update rather than the whole file.
wd_state_set() {
local key="$1" value="$2" file="$3" sep="${4:-:}"
grep -vE "^${key}${sep}" "$file" 2>/dev/null > "${file}.tmp"
echo "${key}${sep}${value}" >> "${file}.tmp"
mv "${file}.tmp" "$file"
local tmp="${file}.tmp"
: > "$tmp" || return 1
if [[ -f "$file" ]]; then
awk -v pfx="${key}${sep}" \
'substr($0, 1, length(pfx)) != pfx' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
fi
echo "${key}${sep}${value}" >> "$tmp"
mv "$tmp" "$file"
}
# ==============================================================================================
@@ -2665,6 +2734,100 @@ check_delete_size_threshold() {
fi
}
# Applies a per-run delete budget instead of refusing the whole run. Reads "<size>\t<ctime>\t<path>"
# from $1, writes the paths that fit inside $3 GB to $2 (oldest ctime first), and reports what was
# held back in _BUDGET_*.
#
# A cap that aborts cannot drain a backlog larger than itself. Every run re-finds the same lump,
# exceeds the same ceiling and stops, so the queue is stuck permanently and only a human with
# --i-know-what-im-doing can clear it. Observed 2026-08-24..26: a partnership merge restored ten
# months of superseded Radarr files in one night, 126GB against a 100GB cap, and the daily
# orchestrator then failed three nights running on a queue it could never drain. Budgeting keeps
# the ceiling meaning exactly what it meant — no single run removes more than max_gb — while the
# backlog clears over consecutive runs with nobody touching it.
#
# Strict oldest-first, stopping at the first file that does not fit rather than skipping it for a
# smaller one further down. Skipping would drain more per run but lets a large file be passed over
# indefinitely; stopping guarantees every deferred file is newer than everything already removed,
# so nothing can starve.
#
# One case is deliberately left for a human: a single file larger than the entire budget can never
# fit, so _BUDGET_STUCK is set and the caller says so rather than looping forever on it.
apply_delete_budget() {
local src="$1" dst="$2" max_gb="$3"
local max_bytes tab
tab=$(printf '\t')
max_bytes=$(awk "BEGIN {printf \"%d\", $max_gb * 1073741824}")
_BUDGET_KEPT_BYTES=0; _BUDGET_KEPT_COUNT=0
_BUDGET_DEFERRED_BYTES=0; _BUDGET_DEFERRED_COUNT=0
_BUDGET_STUCK=""
: > "$dst"
[[ -s "$src" ]] || return 0
local out
out=$(sort -t"$tab" -k2,2n "$src" | awk -F'\t' -v max="$max_bytes" -v dst="$dst" '
{
if (stop) { defb += $1; defc++; next }
if (used + $1 > max) { stop = 1
if (kept == 0) stuck = $3
defb += $1; defc++; next }
used += $1; kept++
print $3 > dst
}
END { printf "%d\t%d\t%d\t%d\t%s", used+0, kept+0, defb+0, defc+0, stuck }
')
IFS=$'\t' read -r _BUDGET_KEPT_BYTES _BUDGET_KEPT_COUNT \
_BUDGET_DEFERRED_BYTES _BUDGET_DEFERRED_COUNT _BUDGET_STUCK <<< "$out"
}
# Asks the local generation model to characterise a block of evidence, for the one line a human
# would otherwise have to derive by hand. Prints the note on stdout and returns 0; returns 1 and
# prints nothing whenever anything at all is missing, off, slow or malformed.
#
# The contract is that failure is indistinguishable from the feature being switched off, because
# every caller must behave identically either way. An assist that can block a nightly cleanup is
# not an assist — so this never retries, never blocks longer than its timeout, and never returns
# a partial answer for a caller to interpret.
#
# Usage: note=$(ai_assist_note AI_ASSIST_CLEANUP "$prompt") && echo "$note"
# $1 name of the AI_ASSIST_* flag governing this caller — passed by name, checked here, so a
# caller cannot accidentally run an assist its own toggle says is off
# $2 the prompt, evidence included
# $3 timeout in seconds (default AI_ASSIST_TIMEOUT, else 45)
ai_assist_note() {
local flag="$1" prompt="$2" ai_timeout="${3:-${AI_ASSIST_TIMEOUT:-45}}"
[[ "${AI_ENABLED:-false}" == "true" ]] || return 1
[[ "${!flag:-false}" == "true" ]] || return 1
[[ -n "${MY_ID:-}" ]] || return 1
command -v jq >/dev/null 2>&1 || return 1
local u_var="${MY_ID}_OLLAMA_URL" m_var="${MY_ID}_OLLAMA_MODEL"
local url="${!u_var:-}" model="${!m_var:-}"
[[ -n "$url" && -n "$model" ]] || return 1
local body out
# temperature 0: this is a description of evidence, and the same evidence should not produce a
# different characterisation on a rerun.
body=$(jq -nc --arg m "$model" --arg p "$prompt" \
'{model:$m, prompt:$p, stream:false, options:{temperature:0}}' 2>/dev/null) || return 1
out=$(timeout "$ai_timeout" curl -sf --max-time "$ai_timeout" \
-H 'Content-Type: application/json' -d "$body" \
"${url%/}/api/generate" 2>/dev/null | jq -r '.response // empty' 2>/dev/null)
# Reasoning models emit a <think> block before the answer. It is not the note — a log line a
# human is meant to skim cannot open with several hundred words of the model talking itself
# through the arithmetic. Strip to the last close tag; a response with no block is unchanged.
[[ "$out" == *"</think>"* ]] && out="${out##*</think>}"
out="${out#"${out%%[![:space:]]*}"}"
[[ -n "$out" ]] || return 1
printf '%s\n' "$out"
}
# True if filepath's extension (case-insensitive) matches one of the given extensions.
# Usage: has_extension "$filepath" "${LIDARR_EXTENSIONS[@]}"
has_extension() {
+11 -11
View File
@@ -20,6 +20,16 @@
# 4. Sources common.sh — shared functions: detect_hosts, logging, notifications, etc.
# 5. Sources Plugin/<platform>/adapter.sh — platform_*() functions for OS-specific ops
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Zero-Script Expansion
# Without this loader, adding a new server required updating every script to
# source the new host conf. With this loader: create host3.conf in the repo,
# commit and push — all servers auto-discover it on next git pull. No script
# changes required.
#
# USAGE IN SCRIPTS
# Scripts in subdirectories (Rsync/, Docker_Essentials/ etc.):
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -96,16 +106,6 @@
# called by the script itself afterwards.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Zero-Script Expansion
# Without this loader, adding a new server required updating every script to
# source the new host conf. With this loader: create host3.conf in the repo,
# commit and push — all servers auto-discover it on next git pull. No script
# changes required.
#
# ==============================================================================================
# ━━━ Locate config root ━━━
# load_config.sh always lives in the repo root.
@@ -220,4 +220,4 @@
AI_TOKEN_CACHE_DIR AI_JOB_DIR DOCKER_JOB_DIR
# ━━━ Cleanup ━━━
unset _conf _host_confs_loaded _adapter LOAD_CONFIG_DIR
unset _conf _host_confs_loaded _adapter LOAD_CONFIG_DIR