Files
Gmer4Lfe 91357a2d03 Tell the operator when a finding needs them
A finding nobody is told about is a finding nobody has, and the card added earlier only shows
them to someone who opens the tab. Only needs_operator is announced — an open finding may still
be repaired by the next pass — one notification for all of them, and each is announced once and
stays quiet until the fault changes or gets worse.

vv_notify() hands the message to common.sh's notify() rather than reimplementing the channels,
and calls detect_hosts() explicitly because load_config.sh deliberately does not: without it the
Unraid notification arrives and Discord silently never does. It also reports false when no
channel is switched on at all, since notify() exits 0 either way and a caller believing that
would mark a finding as told and never mention it again.

Notification text is folded to ASCII. Unraid's notifier dropped an em dash outright and left the
double space behind, which was found by sending one and reading what arrived.
2026-08-09 21:59:50 -04:00

122 lines
5.5 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// One pass of the repair sweep. Reads the logs of jobs that finished since the last pass,
// turns known error shapes into findings, probes for a correction, and either writes a proven
// value or leaves the finding for the operator.
//
// OPERATIONAL MODEL
// Called from ai_repair_sweep.sh, which the watchdog orchestrator runs. Everything it does
// lives in include/ai_repair.php so the same code path is what the tests exercise; this file
// owns only the lock, the log line, and the exit code.
//
// DESIGN PRINCIPLES
// Non-fatal, always.
// Exits 0 on a disabled feature, a held lock, or a sweep that found nothing. Repair is an
// enhancement — the orchestrator that runs it has real work to do either side, and this
// must never be the reason a watchdog cycle reports failure.
//
// The summary is logged, not the reasoning.
// What was scanned, what was found, what was written. The findings themselves are the
// record; duplicating their contents into a log would be two copies to keep in step.
//
// OPERATIONAL SAFEGUARDS
// One sweep at a time.
// flock, non-blocking. A pass that overruns its fifteen-minute slot must not have a second
// copy start probing and writing conf underneath it.
//
// Nothing is written unless two switches say so.
// AI_REPAIR_ENABLED gates the sweep; AI_REPAIR_AUTOFIX_ENABLED gates writing. With only
// the first, this reads and files and changes no configuration at all.
//
// RUNTIME MODES
// ai_repair_sweep.php one pass
// ai_repair_sweep.php --dry-run probe and report, write nothing, leave the marker alone
// ai_repair_sweep.php --status what the last pass did, and what is open
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/ai_repair.php';
$args = array_slice($argv ?? [], 1);
$dryRun = in_array('--dry-run', $args, true);
$status = in_array('--status', $args, true);
function rlog(string $msg): void {
if (!is_dir(LOG_DIR)) return;
@file_put_contents(LOG_DIR . '/ai_repair.log',
date('Y-m-d H:i:s') . ' ' . $msg . "\n", FILE_APPEND | LOCK_EX);
}
if ($status) {
$last = vv_ai_sweep_last();
$open = vv_ai_findings_list(['open', 'needs_operator']);
printf("repair: %s\n", vv_ai_repair_enabled() ? 'enabled' : 'disabled');
printf("autofix: %s\n", vv_ai_repair_autofix_enabled() ? 'enabled' : 'disabled (detect only)');
printf("last pass: %s\n", $last ? date('Y-m-d H:i:s', $last) : 'never');
printf("open findings: %d\n", count($open));
// 'ref' rather than 'conf_key' — it is the conf key for the kinds that have one, and the
// thing that identifies the finding for the kinds that do not. Printing the key left every
// arr health row with a blank column.
foreach ($open as $f) {
printf(" %-7s %-9s %-28s seen %-4d %-14s %s\n",
'[' . ($f['severity'] ?? '?') . ']',
$f['subject'] ?? '?',
$f['ref'] ?? ($f['conf_key'] ?? '?'),
(int)($f['seen'] ?? 0),
$f['state'] ?? '?',
mb_substr((string)($f['observed'] ?? ''), 0, 44));
}
exit(0);
}
if (!vv_ai_repair_enabled()) exit(0); // silent: a disabled feature is not an event
$lockPath = VV_CACHE_ROOT . '/ai_repair.lock';
if (!is_dir(dirname($lockPath))) @mkdir(dirname($lockPath), 0755, true);
$lock = @fopen($lockPath, 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
if ($lock) fclose($lock);
rlog('skipped — a sweep is already running');
exit(0);
}
try {
$t0 = microtime(true);
$sum = vv_ai_repair_sweep($dryRun);
$ms = (int)round((microtime(true) - $t0) * 1000);
if (!($sum['ok'] ?? false)) {
rlog('sweep refused — ' . ($sum['error'] ?? 'unknown'));
exit(0);
}
// An announcement is worth a line whether or not this pass found anything new — a delivery
// that failed on an earlier pass is retried here, and "we tried to tell you" is exactly the
// thing someone reads this log to check.
$ann = $sum['announced'] ?? [];
if (($ann['count'] ?? 0) > 0) {
rlog(sprintf('%sannounce: %d finding(s) — %s%s',
$dryRun ? 'dry-run: ' : '', $ann['count'],
($ann['sent'] ?? false) ? 'sent' : ($dryRun ? 'not sent (dry run)' : 'DELIVERY FAILED'),
' — ' . ($ann['subject'] ?? '')));
}
// Nothing found and nothing to say. A line every fifteen minutes reporting no news is how a
// log stops being read.
if ($sum['findings'] === 0) {
if ($sum['runs'] > 0 && $dryRun) rlog(sprintf('dry-run: %d run(s), nothing found (%dms)', $sum['runs'], $ms));
exit(0);
}
rlog(sprintf('%s%d run(s): %d finding(s), %d fixed, %d for the operator, %d resolved, %d quiet (%dms)',
$dryRun ? 'dry-run: ' : '', $sum['runs'], $sum['findings'], $sum['fixed'],
$sum['needs_operator'], $sum['resolved'], $sum['quiet'], $ms));
foreach ($sum['details'] as $d) rlog(' ' . $d);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
exit(0);