Attribute findings to the host they are about and the host that answered

Identity now includes the host: "Bazarr is on the skip list" is a different fact
on each machine that says it, and without the host in the hash the second node
to report a subject would land on the first node's record — one machine's
dismissal silencing another's live fault.

Nine dismissed findings existed when this changed, so the write path migrates a
legacy id once, at the only moment it matters. Verified: a dismissal survives the
rename rather than resurfacing as new.

acted_by is stamped in apply_action, not set_state — the sweep calls set_state to
move a candidate to needs_operator, and crediting a machine for classifying is
not the same as recording who decided.
This commit is contained in:
Gmer4Lfe
2026-08-14 22:39:38 -04:00
parent 90774f1442
commit b708a90548
+66 -1
View File
@@ -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'];