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:
@@ -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'];
|
||||
|
||||
Reference in New Issue
Block a user