Prove a repair before writing it, and gate writing separately from looking
A model can be confident a port should be 8686; a probe can report that 8686 answered, and only the second is a fact. Detecting and repairing are also separate things to trust, so the feature runs read-only until the write switch is turned on under it.
This commit is contained in:
@@ -1764,6 +1764,20 @@
|
|||||||
AI_ASSIST_CLEANUP=false # tier 2 — orphan and stuck-import triage
|
AI_ASSIST_CLEANUP=false # tier 2 — orphan and stuck-import triage
|
||||||
AI_ASSIST_ONBOARD=false # tier 3 — onboarding / settings assistance
|
AI_ASSIST_ONBOARD=false # tier 3 — onboarding / settings assistance
|
||||||
|
|
||||||
|
# ━━━ AI Repair ━━━
|
||||||
|
# Reads the log of a job that has finished, turns known error shapes into findings, and offers
|
||||||
|
# or applies a repair. Two switches, because detecting and repairing are separate things to
|
||||||
|
# trust.
|
||||||
|
#
|
||||||
|
# AI_REPAIR_ENABLED on its own reads, files findings and offers fixes, and writes nothing. Run
|
||||||
|
# it there first — long enough to read what it finds and disagree with some of it.
|
||||||
|
#
|
||||||
|
# AI_REPAIR_AUTOFIX_ENABLED is what allows a value to be written unattended, and only ever a
|
||||||
|
# value a probe has answered on. Never a toggle: whether something should be switched on is a
|
||||||
|
# decision about intent, and a probe cannot prove intent the way it can prove a port answers.
|
||||||
|
AI_REPAIR_ENABLED=false
|
||||||
|
AI_REPAIR_AUTOFIX_ENABLED=false
|
||||||
|
|
||||||
# ━━━ AI Repair Findings ━━━
|
# ━━━ AI Repair Findings ━━━
|
||||||
# Misconfigurations found in this installation, as opposed to defects in Varaverk — those go to
|
# Misconfigurations found in this installation, as opposed to defects in Varaverk — those go to
|
||||||
# ai_bugs. A finding is open until the configuration is right, and closes itself when the probe
|
# ai_bugs. A finding is open until the configuration is right, and closes itself when the probe
|
||||||
@@ -1771,6 +1785,10 @@
|
|||||||
# before and here is what fixed it" is worth more than the disk. Open findings are never pruned:
|
# before and here is what fixed it" is worth more than the disk. Open findings are never pruned:
|
||||||
# an unresolved problem does not stop mattering because it is old.
|
# an unresolved problem does not stop mattering because it is old.
|
||||||
AI_FINDING_RETAIN_DAYS=90
|
AI_FINDING_RETAIN_DAYS=90
|
||||||
|
# Seconds a single probe may take. Nothing is written to conf that has not answered a probe, so
|
||||||
|
# this is the budget for proving a candidate — kept short because a sweep may try several, and
|
||||||
|
# an address worth switching to answers quickly or is not worth switching to.
|
||||||
|
AI_PROBE_TIMEOUT=4
|
||||||
|
|
||||||
# ━━━ AI Conf Write Access ━━━
|
# ━━━ AI Conf Write Access ━━━
|
||||||
# Separate switch from AI_ENABLED, off by default, and an explicit key whitelist. Never paths,
|
# Separate switch from AI_ENABLED, off by default, and an explicit key whitelist. Never paths,
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ function vv_ai_conf_is_toggle(string $key): bool {
|
|||||||
// May the sweep write this without being asked? Two conditions, both required: a probe actually
|
// May the sweep write this without being asked? Two conditions, both required: a probe actually
|
||||||
// answered on the proposed value, and the key is not a toggle.
|
// answered on the proposed value, and the key is not a toggle.
|
||||||
function vv_ai_finding_may_autofix(array $f): bool {
|
function vv_ai_finding_may_autofix(array $f): bool {
|
||||||
|
if (!vv_ai_repair_autofix_enabled()) return false;
|
||||||
if (empty($f['proven'])) return false;
|
if (empty($f['proven'])) return false;
|
||||||
if (($f['proposed'] ?? null) === null) return false;
|
if (($f['proposed'] ?? null) === null) return false;
|
||||||
if (vv_ai_conf_is_toggle((string)($f['conf_key'] ?? ''))) return false;
|
if (vv_ai_conf_is_toggle((string)($f['conf_key'] ?? ''))) return false;
|
||||||
@@ -143,6 +144,27 @@ function vv_ai_finding_severity(array $f): string {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gates ────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Two switches, because detecting and repairing are separate things to trust.
|
||||||
|
//
|
||||||
|
// AI_REPAIR_ENABLED alone gives a system that reads logs, files findings and offers fixes, and
|
||||||
|
// writes nothing. That is the state this should live in first — long enough to read what it
|
||||||
|
// found and disagree with some of it. A subsystem that starts by editing conf has to be believed
|
||||||
|
// before there is any evidence for believing it.
|
||||||
|
//
|
||||||
|
// AI_REPAIR_AUTOFIX_ENABLED is what lets a proven value be written without being asked, and it
|
||||||
|
// is meaningless on its own: nothing to write if nothing is looking. Both must be true, in the
|
||||||
|
// same layered way AI_ENABLED is necessary but never sufficient.
|
||||||
|
function vv_ai_repair_enabled(): bool {
|
||||||
|
if (!vv_ai_config()['enabled']) return false;
|
||||||
|
return strtolower(trim((string)(vv_conf_vars()['AI_REPAIR_ENABLED'] ?? 'false'))) === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
function vv_ai_repair_autofix_enabled(): bool {
|
||||||
|
if (!vv_ai_repair_enabled()) return false;
|
||||||
|
return strtolower(trim((string)(vv_conf_vars()['AI_REPAIR_AUTOFIX_ENABLED'] ?? 'false'))) === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
function vv_ai_findings_dir(): string {
|
function vv_ai_findings_dir(): string {
|
||||||
$d = AI_DATA_DIR . '/ai_findings';
|
$d = AI_DATA_DIR . '/ai_findings';
|
||||||
if (!is_dir($d)) @mkdir($d, 0755, true);
|
if (!is_dir($d)) @mkdir($d, 0755, true);
|
||||||
@@ -502,3 +524,153 @@ function vv_ai_triage_log(array $lines, string $sourceLog = ''): array {
|
|||||||
}
|
}
|
||||||
return array_values($found);
|
return array_values($found);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Proving a candidate ──────────────────────────────────────────────────────────────────────
|
||||||
|
// The guard the whole unattended path rests on: nothing is written that has not answered.
|
||||||
|
//
|
||||||
|
// A model can be confident that a port should be 8686. A probe can report that 8686 answered.
|
||||||
|
// Only the second is a fact, and only facts get written to conf without being asked. Everything
|
||||||
|
// a probe cannot settle becomes a conversation instead — which is not a lesser outcome, it is
|
||||||
|
// the honest one for a value that cannot be derived from this machine.
|
||||||
|
//
|
||||||
|
// Deliberately narrow. These check reachability and identity, never correctness of behaviour:
|
||||||
|
// that Lidarr answers on 8686 does not prove 8686 is the port you meant, only that something is
|
||||||
|
// listening there and calling itself Lidarr. Proving intent is not a probe's job.
|
||||||
|
|
||||||
|
function vv_ai_probe_timeout(): int {
|
||||||
|
return max(1, (int)(vv_conf_vars()['AI_PROBE_TIMEOUT'] ?? 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does this URL answer at all? Any HTTP status counts, including 401 — a refusal is proof that
|
||||||
|
// something is listening and speaking HTTP, which is exactly what an address probe is asking.
|
||||||
|
// Distinguishing "wrong address" from "wrong credential" is the point of having both kinds.
|
||||||
|
function vv_ai_probe_url(string $url): array {
|
||||||
|
if (!preg_match('#^https?://[^\s/$.?\#][^\s]*$#i', $url)) {
|
||||||
|
return ['ok' => false, 'reason' => 'not a url'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_NOBODY => true,
|
||||||
|
CURLOPT_TIMEOUT => vv_ai_probe_timeout(),
|
||||||
|
CURLOPT_CONNECTTIMEOUT => vv_ai_probe_timeout(),
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false, // these are LAN and Tailscale endpoints, often self-signed
|
||||||
|
CURLOPT_SSL_VERIFYHOST => false,
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
$code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
return $code > 0
|
||||||
|
? ['ok' => true, 'code' => $code]
|
||||||
|
: ['ok' => false, 'reason' => $err !== '' ? $err : 'no response'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every container on this host, by exact name. The membership test for unknown_target findings,
|
||||||
|
// and one docker call rather than one per candidate.
|
||||||
|
function vv_ai_container_names(): array {
|
||||||
|
static $names = null;
|
||||||
|
if ($names !== null) return $names;
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
@exec('timeout ' . vv_ai_probe_timeout() . " docker ps -a --format '{{.Names}}' 2>/dev/null", $out, $rc);
|
||||||
|
return $names = ($rc === 0) ? array_values(array_filter(array_map('trim', $out))) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate corrections for a URL whose host or port stopped answering.
|
||||||
|
//
|
||||||
|
// Only two transformations, both conservative: the same host on a port that some other conf key
|
||||||
|
// already uses, and the same port on a host some other conf key already names. Both draw
|
||||||
|
// exclusively from values already present in this installation's conf — nothing is invented, and
|
||||||
|
// a scan of the port range is deliberately not attempted. Finding *a* listening port is not the
|
||||||
|
// same as finding the right service, and a probe that accepts any answer would happily point
|
||||||
|
// Lidarr at Sonarr.
|
||||||
|
function vv_ai_url_candidates(string $observed): array {
|
||||||
|
$parts = @parse_url($observed);
|
||||||
|
if (!is_array($parts) || empty($parts['host'])) return [];
|
||||||
|
|
||||||
|
$hosts = $ports = [];
|
||||||
|
foreach (vv_conf_vars() as $k => $v) {
|
||||||
|
$v = trim((string)$v);
|
||||||
|
if (!preg_match('#^https?://#i', $v)) continue;
|
||||||
|
$p = @parse_url($v);
|
||||||
|
if (!is_array($p) || empty($p['host'])) continue;
|
||||||
|
$hosts[$p['host']] = true;
|
||||||
|
if (!empty($p['port'])) $ports[(int)$p['port']] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = $parts['scheme'] ?? 'http';
|
||||||
|
$path = $parts['path'] ?? '';
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach (array_keys($ports) as $port) {
|
||||||
|
$c = $scheme . '://' . $parts['host'] . ':' . $port . $path;
|
||||||
|
if ($c !== $observed) $out[$c] = true;
|
||||||
|
}
|
||||||
|
foreach (array_keys($hosts) as $host) {
|
||||||
|
$port = !empty($parts['port']) ? ':' . $parts['port'] : '';
|
||||||
|
$c = $scheme . '://' . $host . $port . $path;
|
||||||
|
if ($c !== $observed) $out[$c] = true;
|
||||||
|
}
|
||||||
|
return array_keys($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to prove a correction for one finding. Returns the finding with 'proposed' and 'proven'
|
||||||
|
// filled in, or unchanged when nothing could be proven — which is the common case and not a
|
||||||
|
// failure.
|
||||||
|
//
|
||||||
|
// auth_rejected and missing_value are never proven here on purpose. A credential cannot be
|
||||||
|
// derived from this host by definition: if it could be read from somewhere, it would not be a
|
||||||
|
// credential. Those go straight to the operator.
|
||||||
|
function vv_ai_probe_finding(array $f): array {
|
||||||
|
$kind = (string)($f['kind'] ?? '');
|
||||||
|
|
||||||
|
if ($kind === 'unknown_target') {
|
||||||
|
$observed = (string)($f['observed'] ?? '');
|
||||||
|
// Exact membership only. A container name is a literal, and "close to an existing name"
|
||||||
|
// is how a repair renames the wrong thing.
|
||||||
|
if (in_array($observed, vv_ai_container_names(), true)) {
|
||||||
|
return $f; // it exists after all — nothing to correct
|
||||||
|
}
|
||||||
|
return $f + ['state' => 'needs_operator'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($kind !== 'unreachable') {
|
||||||
|
// Nothing on this machine can supply a credential, so there is nothing to prove.
|
||||||
|
$f['state'] = 'needs_operator';
|
||||||
|
return $f;
|
||||||
|
}
|
||||||
|
|
||||||
|
$observed = (string)($f['observed'] ?? '');
|
||||||
|
|
||||||
|
// If the observed address answers now, the fault has cleared on its own — a host that was
|
||||||
|
// rebooting, most often. Recording a proposal here would repair something already working.
|
||||||
|
if (vv_ai_probe_url($observed)['ok']) {
|
||||||
|
$f['state'] = 'resolved';
|
||||||
|
$f['note'] = 'Answered when probed — the address was reachable again by the time this ran.';
|
||||||
|
return $f;
|
||||||
|
}
|
||||||
|
|
||||||
|
$answered = [];
|
||||||
|
foreach (vv_ai_url_candidates($observed) as $candidate) {
|
||||||
|
if (vv_ai_probe_url($candidate)['ok']) $answered[] = $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly one, or none. Two addresses answering means the probe cannot say which is the
|
||||||
|
// right one, and picking either is the guess this exists to prevent.
|
||||||
|
if (count($answered) === 1) {
|
||||||
|
$f['proposed'] = $answered[0];
|
||||||
|
$f['proven'] = true;
|
||||||
|
$f['note'] = 'Probed ' . $answered[0] . ' and it answered; ' . $observed . ' did not.';
|
||||||
|
return $f;
|
||||||
|
}
|
||||||
|
|
||||||
|
$f['proven'] = false;
|
||||||
|
$f['state'] = 'needs_operator';
|
||||||
|
$f['note'] = $answered
|
||||||
|
? 'Several addresses answered (' . implode(', ', $answered) . '), so none was written.'
|
||||||
|
: 'Nothing answered at ' . $observed . ', and no address in the conf answered either.';
|
||||||
|
return $f;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user