diff --git a/Plugin/unraid/include/ai_repair.php b/Plugin/unraid/include/ai_repair.php index f5ca647..a1780e7 100644 --- a/Plugin/unraid/include/ai_repair.php +++ b/Plugin/unraid/include/ai_repair.php @@ -260,7 +260,25 @@ function vv_ai_finding_retain_days(): int { // otherwise — for an arr health item, the check that raised it. "Indexers unavailable: NzbNoob" // becomes "Indexers unavailable: NzbNoob, Miatrix" as more fail, and that is the same finding // getting worse rather than a second one. -function vv_ai_finding_id(string $kind, string $subject, string $ref): string { +// Identity includes the host, because "Bazarr is on the skip list" is a different fact on each +// machine that says it. Without the host in the hash, the second node to report the same subject +// would land on the first node's record and overwrite it — and the operator would see one finding +// where two machines have the same problem, or worse, one machine's dismissal silencing another's +// live fault. +// +// $host defaults to this node, so every existing caller keeps working and local findings are +// unchanged in meaning. It is a parameter rather than always-local because a collected finding +// from a partner has to hash as that partner's, not as ours. +function vv_ai_finding_id(string $kind, string $subject, string $ref, string $host = ''): string { + $host = $host !== '' ? $host : vv_detect_host(); + return substr(sha1(strtolower($host . '|' . $kind . '|' . $subject . '|' . $ref)), 0, 12); +} + +// What the id was before the host joined the hash. Kept solely so a finding already on disk can be +// found once — see the migration in vv_ai_finding_write(). Nine dismissed findings existed when +// this changed, and a dismissal that silently expires is the one outcome this store must never +// produce: "stop telling me about this" has to outlast a refactor. +function vv_ai_finding_id_legacy(string $kind, string $subject, string $ref): string { return substr(sha1(strtolower($kind . '|' . $subject . '|' . $ref)), 0, 12); } @@ -308,6 +326,10 @@ function vv_ai_finding_write(array $f): array { $id = vv_ai_finding_id($kind, $subject, $ref); $rec = [ 'id' => $id, + // Which machine this is about. Written even on a single-host install, because the store + // outlives the topology — a finding filed today is still on disk when the second node + // arrives, and one without a host is a record nobody can place. + 'host' => (string)($f['host'] ?? vv_detect_host()), 'kind' => $kind, 'subject' => mb_substr($subject, 0, 120), 'conf_key' => $confKey, @@ -354,6 +376,18 @@ function vv_ai_finding_write(array $f): array { $p = vv_ai_finding_path($id); if ($p === null) return ['ok' => false, 'error' => 'bad id']; + // One-time rename from the pre-host id, done here rather than as a startup sweep because this + // is the only moment it matters: a finding is being written, and the question is whether this + // node has said it before. A migration that ran anywhere else would have to walk the whole + // store to answer a question only the write path asks. + // + // Idempotent by construction — after the rename the legacy path no longer exists, and a record + // already carrying the new id never looks. + if (!is_file($p)) { + $legacy = vv_ai_finding_path(vv_ai_finding_id_legacy($kind, $subject, $ref)); + if ($legacy !== null && $legacy !== $p && is_file($legacy)) @rename($legacy, $p); + } + if (is_file($p)) { $old = json_decode((string)@file_get_contents($p), true); if (is_array($old)) { @@ -420,6 +454,11 @@ function vv_ai_finding_set_state(string $id, string $state, string $note = ''): $r['closed_at'] = in_array($state, ['open', 'needs_operator'], true) ? null : time(); if ($note !== '') $r['note'] = mb_substr(vv_ai_redact($note), 0, 1000); + // No attribution here on purpose. This setter is called by the sweep as well as by a person — + // it is how a candidate becomes needs_operator — and stamping every transition would credit a + // machine for deciding something it only classified. Attribution belongs to the answer, not to + // the bookkeeping, so vv_ai_finding_apply_action() records it and this does not. + $p = vv_ai_finding_path($id); return $p !== null && @file_put_contents($p, json_encode($r, JSON_PRETTY_PRINT)) !== false; } @@ -1590,6 +1629,20 @@ function vv_ai_finding_action_from_text(string $text): ?string { // Fix goes through the same guarded write path as everything else, and is the one place a // toggle may be written — because reaching here means the operator asked for it by name. The // unattended sweep never calls this. +// Records which node answered a finding and how. Separate from the action itself so every route +// through apply_action() is stamped the same way — a switch arm that forgot would be a finding +// with an outcome and no author, which is exactly the record the mesh needs and the hardest kind +// of gap to notice afterwards. +function vv_ai_finding_stamp_actor(string $id, string $action): bool { + $r = vv_ai_finding_get($id); + if ($r === null) return false; + $r['acted_by'] = vv_detect_host(); + $r['acted_at'] = time(); + $r['acted'] = $action; + $p = vv_ai_finding_path($id); + return $p !== null && @file_put_contents($p, json_encode($r, JSON_PRETTY_PRINT)) !== false; +} + function vv_ai_finding_apply_action(string $id, string $action, string $note = ''): array { $f = vv_ai_finding_get($id); if ($f === null) return ['ok' => false, 'error' => 'no such finding']; @@ -1603,6 +1656,18 @@ function vv_ai_finding_apply_action(string $id, string $action, string $note = ' return ['ok' => false, 'error' => 'not offered for this finding: ' . $action]; } + // Stamped before the action runs, not after, so a move that half-succeeds still records who + // asked for it. 'cancel' is excluded because it is the button for changing your mind, and + // recording it would make "nobody did anything" look like a decision. + // + // Deliberately the node, not a person: this interface authenticates as root and has no user + // to name. In a mesh the useful question is which machine answered — one node dismissing what + // another raised is the case worth being able to reconstruct. + if ($action !== 'cancel') { + vv_ai_finding_stamp_actor($id, $action); + $f = vv_ai_finding_get($id) ?? $f; + } + switch ($action) { case 'ack': return ['ok' => vv_ai_finding_ack($id, $note), 'action' => 'ack'];