Compare commits

..
2 Commits
Author SHA1 Message Date
Gmer4Lfe 77fb1abb85 Put the findings where they can be answered
Seven findings had been sitting in needs_operator with no way to reach the operator: the only
way to read one was the sweep tool over SSH. The card states the gate as well as the list,
because an empty list means nothing found when repair is on and nothing is looking when it is
off. Buttons come from the actions the endpoint returned for that row, so a tab left open
overnight cannot offer a choice the store has moved past.
2026-08-09 21:14:44 -04:00
Gmer4Lfe b20a46ba81 Let an acknowledgement expire on a finding that has no conf key
An ack is supposed to hold only while the thing acknowledged is still true, but a finding with
no key compared '' against '' and so was silenced permanently — dismiss wearing ack's label,
which is every arr health finding on this host. Those pin to the shape of the fault instead.
Dismiss and reopen become real answers rather than states only reachable by editing JSON, and
apply_action now refuses anything the finding did not offer.
2026-08-09 21:14:36 -04:00
3 changed files with 337 additions and 20 deletions
+58
View File
@@ -65,6 +65,8 @@
// GET ?action=memory_get the operator memory file and its budget // GET ?action=memory_get the operator memory file and its budget
// GET ?action=chats stored conversations, newest first, metadata only // GET ?action=chats stored conversations, newest first, metadata only
// GET ?action=chat_get&id=<hex32> one stored conversation with its transcript // GET ?action=chat_get&id=<hex32> one stored conversation with its transcript
// GET ?action=findings [all=1] repair findings, open only unless all=1
// POST action=finding_action id=<hex12> act=fix|ack|dismiss|reopen|cancel [note=…]
// POST action=ask question=… [history=<JSON>] [kind=…] [think=0|1] // POST action=ask question=… [history=<JSON>] [kind=…] [think=0|1]
// POST action=memory_set memory=… replace the memory file // POST action=memory_set memory=… replace the memory file
// POST action=clear token=<hex32> discard a finished job // POST action=clear token=<hex32> discard a finished job
@@ -77,11 +79,14 @@
// poll {"ok":true,"job":{"status":"retrieving|generating|done|error",…}} // poll {"ok":true,"job":{"status":"retrieving|generating|done|error",…}}
// clear {"ok":true} // clear {"ok":true}
// chats {"ok":true,"chats":[{id,ts,profile,title,turns}],"max":N} // chats {"ok":true,"chats":[{id,ts,profile,title,turns}],"max":N}
// findings {"ok":true,"repair":{enabled,autofix,last},"findings":[…],"counts":{…}}
// finding_action {"ok":true,"action":"fix"}
// chat_save {"ok":true,"id":"<hex32>","title":…} // chat_save {"ok":true,"id":"<hex32>","title":…}
// {"ok":false,"error":…} // {"ok":false,"error":…}
// //
// DEPENDS ON // DEPENDS ON
// include/ai.php vv_ai_stats(), vv_ai_config(), vv_ai_job_*() // include/ai.php vv_ai_stats(), vv_ai_config(), vv_ai_job_*()
// include/ai_repair.php the findings store — loaded only by the two actions that read it
// Tools/ai_chat_worker.php the detached worker // Tools/ai_chat_worker.php the detached worker
// ═══════════════════════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════════════════════
// First executable statement, deliberately dependency-free. A request that is rejected by the // First executable statement, deliberately dependency-free. A request that is rejected by the
@@ -290,6 +295,59 @@ if ($action === 'bug_close') {
exit; exit;
} }
// ── findings / finding_action ─────────────────────────────────────────────────
// What the repair sweep found, and the operator's answer to it. include/ai_repair.php is pulled
// in here rather than at the top of the file: it is the largest include in the plugin and poll
// runs once a second per open tab, so it is loaded by the two actions that need it and by nothing
// else.
//
// Neither action is gated on AI_REPAIR_ENABLED. Findings filed while it was on do not stop being
// true when it goes off, and answering them — including saying "this was never a problem" — is
// exactly what an operator turning the feature off is likely to want to do first. The gate states
// are reported instead, so the card can say what is running rather than the endpoint pretending
// the store is empty.
if ($action === 'findings' || $action === 'finding_action') {
require_once dirname(__DIR__) . '/include/ai_repair.php';
if ($action === 'findings') {
// Closed findings are the history — what was dismissed, what a fix actually fixed — and
// they are asked for explicitly rather than shipped with every poll of the open list.
$rows = [];
$open = 0; $needs = 0;
foreach (vv_ai_findings_list(($_GET['all'] ?? '') === '1' ? [] : ['open', 'needs_operator']) as $f) {
$state = (string)($f['state'] ?? 'open');
if ($state === 'open') $open++;
elseif ($state === 'needs_operator') $needs++;
// The three things the page must not decide for itself: which actions this row
// offers, and what its state and kind mean in words.
$f['actions'] = vv_ai_finding_actions($f);
$f['state_label'] = VV_AI_FINDING_STATES[$state] ?? '';
$f['kind_label'] = VV_AI_FINDING_KINDS[(string)($f['kind'] ?? '')] ?? '';
$rows[] = $f;
}
echo json_encode(['ok' => true,
'repair' => ['enabled' => vv_ai_repair_enabled(),
'autofix' => vv_ai_repair_autofix_enabled(),
'last' => vv_ai_sweep_last()],
'findings' => $rows,
'counts' => ['open' => $open, 'needs_operator' => $needs, 'shown' => count($rows)]]);
exit;
}
// POST, because fix writes conf through the guarded path and every other answer writes state.
// Which actions are legal for a given row is vv_ai_finding_apply_action()'s call, not this
// endpoint's — a tab left open overnight is holding buttons the store has moved past.
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$fid = trim($_POST['id'] ?? '');
$act = trim($_POST['act'] ?? '');
$r = vv_ai_finding_apply_action($fid, $act, trim($_POST['note'] ?? ''));
vv_ai_log(sprintf('finding_action id=%s act=%s %s', $fid, $act,
$r['ok'] ? 'ok' : 'FAILED: ' . ($r['error'] ?? '?')));
echo json_encode($r);
exit;
}
// ── incident_add ────────────────────────────────────────────────────────────── // ── incident_add ──────────────────────────────────────────────────────────────
// Appends one operator-written "this was the fix" note against a scope. POST only, and the // Appends one operator-written "this was the fix" note against a scope. POST only, and the
// scope is whitelisted the same way ask's is — it is written to a file that later rides in a // scope is whitelisted the same way ask's is — it is written to a file that later rides in a
+79 -12
View File
@@ -308,11 +308,13 @@ function vv_ai_finding_write(array $f): array {
$rec['closed_at'] = $old['closed_at'] ?? null; $rec['closed_at'] = $old['closed_at'] ?? null;
} }
// An acknowledgement holds only while the thing acknowledged is still true. Compare // An acknowledgement holds only while the thing acknowledged is still true. Compare
// the live value against what it read when the ack was given: unchanged means stay // what it is pinned to now against what it read when the ack was given: unchanged
// quiet, changed means the note is stale and the finding comes back by itself. // means stay quiet, changed means the note is stale and the finding comes back by
// itself. $rec carries this sighting's evidence, so a fault that has changed shape
// fails this comparison even when no conf key is involved.
if (($old['state'] ?? '') === 'acknowledged') { if (($old['state'] ?? '') === 'acknowledged') {
$ackedAt = (string)($old['ack_value'] ?? ''); $ackedAt = (string)($old['ack_value'] ?? '');
if ($ackedAt === (string)(vv_conf_vars()[$confKey] ?? '')) { if ($ackedAt === vv_ai_finding_ack_pin($rec)) {
$rec['state'] = 'acknowledged'; $rec['state'] = 'acknowledged';
$rec['ack_value'] = $ackedAt; $rec['ack_value'] = $ackedAt;
$rec['closed_at'] = $old['closed_at'] ?? null; $rec['closed_at'] = $old['closed_at'] ?? null;
@@ -365,17 +367,32 @@ function vv_ai_finding_dismiss(string $id, string $note = ''): bool {
return vv_ai_finding_set_state($id, 'dismissed', $note); return vv_ai_finding_set_state($id, 'dismissed', $note);
} }
// What an acknowledgement is pinned to — the thing that has to stay the same for the ack to keep
// meaning what it meant.
//
// For a conf-bound finding that is the key's value, which is what the operator was looking at
// when they said "I know". A finding with no key had nothing to pin to and so compared '' with
// '' — every ack on an arr health item was silently permanent, which is dismiss wearing ack's
// label. Those pin to the shape of the fault instead: "indexers unavailable: NzbNoob" and
// "indexers unavailable: NzbNoob, Miatrix" are one finding getting worse, and an ack given for
// the first has not been given for the second.
function vv_ai_finding_ack_pin(array $f): string {
$key = (string)($f['conf_key'] ?? '');
if ($key !== '') return (string)(vv_conf_vars()[$key] ?? '');
return 'ev:' . substr(sha1((string)($f['evidence'] ?? '')), 0, 16);
}
// "I know about this — leave it, and tell me if it changes." // "I know about this — leave it, and tell me if it changes."
// //
// Stamps the key's current value onto the record. Every later sighting compares against that // Stamps what it is pinned to onto the record. Every later sighting compares against that stamp,
// stamp, so the acknowledgement covers this state and not the key forever. Acking that critical // so the acknowledgement covers this state and not the finding forever. Acking that critical
// rsync is off says nothing about critical rsync being on. // rsync is off says nothing about critical rsync being on.
function vv_ai_finding_ack(string $id, string $note = ''): bool { function vv_ai_finding_ack(string $id, string $note = ''): bool {
$r = vv_ai_finding_get($id); $r = vv_ai_finding_get($id);
if ($r === null) return false; if ($r === null) return false;
$r['state'] = 'acknowledged'; $r['state'] = 'acknowledged';
$r['ack_value'] = (string)(vv_conf_vars()[$r['conf_key'] ?? ''] ?? ''); $r['ack_value'] = vv_ai_finding_ack_pin($r);
$r['closed_at'] = time(); $r['closed_at'] = time();
if ($note !== '') $r['note'] = mb_substr(vv_ai_redact($note), 0, 1000); if ($note !== '') $r['note'] = mb_substr(vv_ai_redact($note), 0, 1000);
@@ -384,12 +401,23 @@ function vv_ai_finding_ack(string $id, string $note = ''): bool {
} }
// What the operator can do about a finding, and what each choice means. Returned rather than // What the operator can do about a finding, and what each choice means. Returned rather than
// hardcoded in the UI so the chat and the page cannot offer different options for the same row. // hardcoded in the UI so the chat and the page cannot offer different options for the same row,
// and enforced in vv_ai_finding_apply_action() so neither can act on one it was not offered.
// //
// Fix appears for anything with a proposed value, toggle or not — the prohibition is on the // Fix appears for anything with a proposed value, toggle or not — the prohibition is on the
// sweep choosing, never on the operator choosing. Everything carries ack and cancel, because // sweep choosing, never on the operator choosing. Open rows also carry ack, dismiss and cancel,
// "I know" and "not now" are always valid answers to being told something. // because "I know", "this is never a problem" and "not now" are all valid answers to being told
// something, and they are three different answers.
function vv_ai_finding_actions(array $f): array { function vv_ai_finding_actions(array $f): array {
// A closed finding has one question left, and it is not the original one: was closing it
// right? Offering fix or ack on a row that is already dismissed is offering to decide
// something that has been decided. Reopen is here because dismiss is otherwise permanent —
// the write path keeps a dismissed finding dismissed however many times the fault recurs,
// so a mis-click would need someone editing JSON on disk to undo.
if (!in_array((string)($f['state'] ?? 'open'), ['open', 'needs_operator'], true)) {
return ['reopen' => 'Put it back on the list — either closing it was wrong, or it is back'];
}
$actions = []; $actions = [];
if (($f['proposed'] ?? null) !== null) { if (($f['proposed'] ?? null) !== null) {
@@ -398,7 +426,10 @@ function vv_ai_finding_actions(array $f): array {
: 'Write the proven value to ' . $f['conf_key']; : 'Write the proven value to ' . $f['conf_key'];
} }
$actions['ack'] = 'Known and intended. Stays quiet until ' . ($f['conf_key'] ?? 'it') . ' changes'; $actions['ack'] = ($f['conf_key'] ?? '') !== ''
? 'Known and intended. Stays quiet until ' . $f['conf_key'] . ' changes'
: 'Known and intended. Stays quiet until the fault itself changes';
$actions['dismiss'] = 'Not a problem, ever. Stays closed even when it is seen again';
$actions['cancel'] = 'Leave it alone for now'; $actions['cancel'] = 'Leave it alone for now';
return $actions; return $actions;
@@ -597,13 +628,32 @@ const VV_AI_ACTION_PATTERNS = [
], ],
// Not now — no state written, it comes back next sweep. // Not now — no state written, it comes back next sweep.
'cancel' => [ 'cancel' => [
'/\b(not now|later|leave it (alone|for now)|skip( it)?|cancel|ignore for now)\b/u', // "leave it" is matched bare, not only as "leave it alone" / "leave it for now". The
// ambiguity this function is built to refuse — "leave it, I know" reading as both cancel
// and ack — did not actually arise with the longer forms, so that sentence resolved to
// ack and silenced the finding until the fault changed. The looser pattern is what makes
// the two readings collide and sends it back to be restated.
'/\b(not now|later|leave it\b|skip( it)?|cancel|ignore for now)\b/u',
'/\b(no|nope|nah)\b[\s,.!]*$/u', '/\b(no|nope|nah)\b[\s,.!]*$/u',
'/\b(don\'?t|do not) (fix|touch|change|write|apply)\b/u', '/\b(don\'?t|do not) (fix|touch|change|write|apply)\b/u',
], ],
// Never a problem. Deliberately narrow: this is the one answer that cannot expire on its
// own, so it is only read from a sentence that says so outright. Anything vaguer than these
// is meant to land on ack, which comes back by itself when the fault changes.
'dismiss' => [
'/\bdismiss\b/u',
'/\b(this|that|it)(?:\'s| is) not (a |an )?(problem|bug|issue|real)\b/u',
'/\bnever (a problem|an issue|report this)\b/u',
],
// Undo a close.
'reopen' => [
'/\breopen\b/u',
'/\bun-?dismiss\b/u',
],
]; ];
// Returns 'fix' | 'ack' | 'cancel', or null when the reply does not clearly mean one of them. // Returns one of the keys in VV_AI_ACTION_PATTERNS, or null when the reply does not clearly mean
// exactly one of them.
// //
// Only call this when a finding is actually pending. A bare "yes" means fix in answer to "shall // Only call this when a finding is actually pending. A bare "yes" means fix in answer to "shall
// I fix it" and means nothing at all on its own, and the difference is context this function // I fix it" and means nothing at all on its own, and the difference is context this function
@@ -634,10 +684,27 @@ function vv_ai_finding_apply_action(string $id, string $action, string $note = '
$f = vv_ai_finding_get($id); $f = vv_ai_finding_get($id);
if ($f === null) return ['ok' => false, 'error' => 'no such finding']; if ($f === null) return ['ok' => false, 'error' => 'no such finding'];
// Only what this finding actually offers, in the state it is actually in. The page renders
// its buttons from the same function, but a stale tab holds buttons the store has moved past
// — a row acked in one window is still showing Fix in another — and the endpoint is reachable
// without either. Checking here is what makes vv_ai_finding_actions() the authority rather
// than a suggestion.
if (!isset(vv_ai_finding_actions($f)[$action])) {
return ['ok' => false, 'error' => 'not offered for this finding: ' . $action];
}
switch ($action) { switch ($action) {
case 'ack': case 'ack':
return ['ok' => vv_ai_finding_ack($id, $note), 'action' => 'ack']; return ['ok' => vv_ai_finding_ack($id, $note), 'action' => 'ack'];
case 'dismiss':
return ['ok' => vv_ai_finding_dismiss($id, $note), 'action' => 'dismiss'];
// Back to open, never straight back to needs_operator: whether it still cannot be
// repaired here is the next sweep's finding to make, not a state to restore.
case 'reopen':
return ['ok' => vv_ai_finding_set_state($id, 'open', $note), 'action' => 'reopen'];
case 'cancel': case 'cancel':
// Deliberately writes nothing at all. "Not now" is not a state, it is the absence of // Deliberately writes nothing at all. "Not now" is not a state, it is the absence of
// one — recording it would make the finding look decided when it is still open. // one — recording it would make the finding look decided when it is still open.
+199 -7
View File
@@ -47,17 +47,27 @@
// Polling stops on a terminal state, on error, and on a wall-clock ceiling. // Polling stops on a terminal state, on error, and on a wall-clock ceiling.
// A worker that dies without writing would otherwise be polled forever. // A worker that dies without writing would otherwise be polled forever.
// //
// Read-only with respect to the system. Nothing here runs a script, edits conf, or changes // The conversation is read-only with respect to the system. The findings card is not.
// any Varaverk state — it asks questions about documentation. // Asking questions changes nothing, and every profile reachable from the composer holds
// zero capabilities. The one control on this page that changes Varaverk state is Fix on a
// repair finding, which writes a single conf key through vv_conf_edit()'s guarded path —
// lock, backup, syntax check, read-back, rollback. It is confirmed first, it names the
// file, key and both values before it is clicked, and it is the only way a toggle is ever
// written by this subsystem, because reaching it means the operator asked for it by name.
//
// The page never decides what a finding may do.
// Buttons are rendered from the actions api/ai.php returned for that row, and the endpoint
// checks the same list again before acting. A tab left open overnight holds buttons the
// store has moved past, so the row the operator sees is not the authority on what is legal.
// //
// RENDERS // RENDERS
// Status banner (index, model residency, GPU, staleness), chat transcript with collapsible // Status banner (index, model residency, GPU, staleness), repair findings with their actions,
// reasoning and audited sources, composer with retrieval-scope and reasoning controls, // assistant-filed bug reports, chat transcript with collapsible reasoning and audited sources,
// source viewer overlay // composer with retrieval-scope and reasoning controls, source viewer overlay
// //
// DEPENDS ON // DEPENDS ON
// include/ai_chat.php the shared conversation surface, also used by the Monitor tab's AI row // include/ai_chat.php the shared conversation surface, also used by the Monitor tab's AI row
// api/ai.php stats / tokens / bugs / ask / poll / clear / chats // api/ai.php stats / tokens / bugs / findings / finding_action / ask / poll / chats
// api/readscript.php source viewer contents // api/readscript.php source viewer contents
// ═══════════════════════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/ai_chat.php'; require_once dirname(__DIR__) . '/include/ai_chat.php';
@@ -149,6 +159,37 @@ if (is_dir('/var/log/varaverk')) {
white-space:pre-wrap; overflow-x:auto; max-height:110px; overflow-y:auto; } white-space:pre-wrap; overflow-x:auto; max-height:110px; overflow-y:auto; }
.vv-ai-bug-q { font-size:10px; color:#4a4a4a; margin-top:5px; font-style:italic; } .vv-ai-bug-q { font-size:10px; color:#4a4a4a; margin-top:5px; font-style:italic; }
/* ── Repair findings ────────────────────────────────────────────────────── */
/* Deliberately not styled like the bug reports above. A bug report is a note to send someone
else; a finding is a decision waiting on the operator, and the row carries buttons that write
conf. The left border colours by severity so a page of them is scannable without reading. */
.vv-ai-fnd { border-left:2px solid #3a3a3a; background:#0d0d0d; border-radius:0 3px 3px 0;
padding:8px 10px; margin-bottom:8px; }
.vv-ai-fnd.sev-error { border-left-color:#7a3040; background:#140c0e; }
.vv-ai-fnd.sev-warn { border-left-color:#6a5228; background:#130f0a; }
.vv-ai-fnd.closed { opacity:.55; }
.vv-ai-fnd-h { display:flex; align-items:center; gap:9px; margin-bottom:4px; flex-wrap:wrap; }
.vv-ai-fnd-s { font-family:monospace; font-size:11px; color:#c8c8c8; }
.vv-ai-fnd-k { font-size:9px; letter-spacing:.06em; text-transform:uppercase; color:#4a4a4a;
border:1px solid #262626; border-radius:3px; padding:1px 5px; }
.vv-ai-fnd-m { font-size:10px; color:#4a4a4a; font-family:monospace; margin-left:auto; }
.vv-ai-fnd-r { font-size:12px; color:#b8b8b8; line-height:1.5; margin-bottom:5px; }
.vv-ai-fnd-e { margin:0; padding:6px 8px; background:#0b0b0b; border:1px solid #1e1e1e;
border-radius:3px; font-size:10px; line-height:1.5; color:#8a8a8a;
white-space:pre-wrap; overflow-x:auto; max-height:110px; overflow-y:auto; }
/* The proposed write, spelled out in full before anything is clicked. This is the one line that
says what Fix will actually do to conf, so it is not abbreviated and not hidden. */
.vv-ai-fnd-w { font-size:11px; font-family:monospace; color:#8a8a8a; margin-top:6px;
padding:5px 8px; background:#0b0b0b; border:1px solid #1e1e1e; border-radius:3px; }
.vv-ai-fnd-w b { color:#c8c8c8; font-weight:normal; }
.vv-ai-fnd-w .arrow { color:#4a4a4a; }
.vv-ai-fnd-n { font-size:10px; color:#5a5a5a; margin-top:5px; font-style:italic; }
.vv-ai-fnd-a { display:flex; gap:7px; margin-top:7px; align-items:center; flex-wrap:wrap; }
/* Each button explains itself on hover from the server's own text, so the page never has to
restate what an action means and cannot restate it differently. */
.vv-ai-fnd-a .vv-ai-btn { padding:3px 11px; font-size:11px; }
.vv-ai-fnd-msg { font-size:10px; color:#5a5a5a; margin-left:4px; }
/* ── Settings card ──────────────────────────────────────────────────────── */ /* ── Settings card ──────────────────────────────────────────────────────── */
/* Collapsed by default and by markup, not by JS: the card is closed because the class is /* Collapsed by default and by markup, not by JS: the card is closed because the class is
simply absent, so it cannot flash open on a slow load or stick open if a script throws. simply absent, so it cannot flash open on a slow load or stick open if a script throws.
@@ -187,6 +228,25 @@ if (is_dir('/var/log/varaverk')) {
</div> </div>
</div> </div>
<!-- What the repair sweep found. Above the bug reports because these are decisions waiting on
the operator rather than notes to file somewhere, and because until this card existed the
only way to see a finding was to run the sweep tool with --status over SSH.
Unlike the bugs card it does not hide itself when empty. "Nothing found, last swept 6
minutes ago" is the single most useful thing this card ever says, and a card that vanishes
on good news cannot say it — it just leaves a gap that reads as broken. -->
<div class="vv-ai-tok" id="vv-ai-fnd-wrap">
<div class="vv-ai-diag-col" style="grid-column:1/-1">
<div class="vv-ai-diag-h">
<span id="vv-ai-fnd-sum"></span> Self-repair findings
<span class="vv-ai-set-sum" style="margin-left:auto" id="vv-ai-fnd-gate"></span>
<button class="vv-ai-btn ghost" id="vv-ai-fnd-all" type="button"
style="padding:2px 9px;font-size:10px;text-transform:none;letter-spacing:0">Show closed</button>
</div>
<div id="vv-ai-fnd"></div>
</div>
</div>
<!-- Bug reports the scheduler's troubleshooter filed. Hidden entirely when there are none: <!-- Bug reports the scheduler's troubleshooter filed. Hidden entirely when there are none:
an empty card here would be a permanent reminder of nothing, and this row already an empty card here would be a permanent reminder of nothing, and this row already
competes for the space above the transcript. --> competes for the space above the transcript. -->
@@ -527,6 +587,133 @@ vv_ai_chat_markup('vv-ai', [
.then(() => loadBugs()).catch(() => {}); .then(() => loadBugs()).catch(() => {});
}; };
// ── Repair findings ─────────────────────────────────────────────────────
// The sweep files these every 15 minutes and, until this card, the only way to read one was
// ai_repair_sweep.sh --status over SSH. Every button here is an answer the operator is giving
// to something the system noticed — including Fix, which writes conf through the guarded path.
let fndAll = false, fndRows = [];
// Labels are the page's; meanings are not. The title on each button is the server's own
// description of that action, so the wording an operator hovers is the same wording the chat
// uses for the same row.
const FND_LABEL = { fix: 'Fix', ack: 'I know', dismiss: 'Never a problem', reopen: 'Reopen' };
function loadFindings() {
fetch(API + '?action=findings' + (fndAll ? '&all=1' : '')).then(r => r.json())
.then(d => { if (d.ok) renderFindings(d); })
.catch(() => {});
}
function renderFindings(d) {
fndRows = d.findings || [];
const rep = d.repair || {}, c = d.counts || {};
// Gate state is stated on the card rather than left to be inferred from an empty list. An
// empty list means "nothing found" when repair is on and "nothing is looking" when it is off,
// and those are opposite pieces of news.
const gate = [];
gate.push(rep.enabled ? (rep.autofix ? 'repair on · autofix on'
: 'repair on · detect only')
: 'repair off — nothing is looking');
if (rep.last) gate.push('swept ' + ago(rep.last));
$('vv-ai-fnd-gate').innerHTML = rep.enabled
? esc(gate.join(' · '))
: `<span class="vv-ai-warn">${esc(gate.join(' · '))}</span>`;
// needs_operator is counted separately from open because it is the one that is actually
// waiting on a person — an open finding may still be repaired by the next sweep.
const sum = $('vv-ai-fnd-sum');
if (c.needs_operator) sum.innerHTML = `<span class="vv-ai-warn">${c.needs_operator} need${c.needs_operator > 1 ? '' : 's'} you</span>`;
else if (c.open) sum.innerHTML = `<span class="vv-ai-warn">${c.open} open</span>`;
else sum.innerHTML = `<span class="vv-ai-ok">✓ nothing open</span>`;
if (!fndRows.length) {
$('vv-ai-fnd').innerHTML = fndAll
? '<div class="vv-ai-none">nothing filed yet</div>'
: '<div class="vv-ai-none">nothing open — the last sweep found no new faults</div>';
return;
}
$('vv-ai-fnd').innerHTML = fndRows.map(f => {
const closed = ['open', 'needs_operator'].indexOf(f.state) === -1;
const meta = [f.id, f.seen > 1 ? 'seen ' + f.seen + '×' : null, ago(f.last),
closed ? f.state : null].filter(Boolean).join(' · ');
// Buttons come from what the server offered for this row and nothing else. cancel is the
// exception and is dropped: "leave it alone for now" writes nothing by design, which in a
// page is spelled "do not click anything".
const acts = Object.keys(f.actions || {}).filter(a => a !== 'cancel').map(a =>
`<button class="vv-ai-btn${a === 'fix' ? '' : ' ghost'}" data-act="${esc(a)}" `
+ `data-id="${esc(f.id)}" title="${esc(f.actions[a])}">${esc(FND_LABEL[a] || a)}</button>`
).join('');
return `<div class="vv-ai-fnd sev-${esc(f.severity || 'warn')}${closed ? ' closed' : ''}">
<div class="vv-ai-fnd-h">
<span class="vv-ai-fnd-s">${esc(f.subject)}</span>
<span class="vv-ai-fnd-k" title="${esc(f.kind_label || '')}">${esc(f.kind)}</span>
<span class="vv-ai-fnd-m">${esc(meta)}</span>
</div>
<div class="vv-ai-fnd-r">${esc(f.ref)}</div>
<pre class="vv-ai-fnd-e">${esc(f.evidence)}</pre>
${f.proposed !== null && f.proposed !== undefined
? `<div class="vv-ai-fnd-w">${esc(f.conf_file)} · <b>${esc(f.conf_key)}</b> `
+ `${esc(f.observed || '(empty)')} <span class="arrow">→</span> <b>${esc(f.proposed)}</b>`
+ (f.proven ? '' : ' <span class="vv-ai-warn">· unproven</span>') + `</div>`
: ''}
${f.note ? `<div class="vv-ai-fnd-n">${esc(f.note)}</div>` : ''}
<div class="vv-ai-fnd-a">${acts}<span class="vv-ai-fnd-msg" data-msg="${esc(f.id)}"></span></div>
</div>`;
}).join('');
}
// Delegated, so the markup carries no inline handler and nothing has to be escaped into an
// attribute that runs as code.
$('vv-ai-fnd').addEventListener('click', e => {
const btn = e.target.closest('button[data-act]');
if (!btn) return;
const id = btn.dataset.id, act = btn.dataset.act;
const f = fndRows.find(x => x.id === id);
if (!f) return;
// The two that cannot be walked back get asked about first. Fix edits a conf file, and
// dismiss is the one state the sweep will never reopen on its own however many times the
// fault comes back.
if (act === 'fix' && !confirm(
`Write ${f.conf_key} = ${f.proposed}\n\nin ${f.conf_file}, replacing ${f.observed || '(empty)'}.`
+ (f.proven ? '' : '\n\nNothing has probed this value — it is a proposal, not a proven fix.')))
return;
if (act === 'dismiss' && !confirm(
`Dismiss "${f.subject} — ${f.ref}" permanently?\n\nIt stays closed even when it is seen `
+ `again. To be told if it changes, use "I know" instead.`)) return;
const msg = $('vv-ai-fnd').querySelector(`[data-msg="${id}"]`);
const btns = Array.from(btn.parentNode.querySelectorAll('button'));
btns.forEach(b => b.disabled = true);
if (msg) msg.textContent = 'working…';
// Reload on success, never on failure. A refused conf write is the case that must not
// disappear quietly: the guarded path rolled back, the finding is unchanged, and a reload
// would repaint the row identically half a second later and take the error with it. So the
// failed row keeps its message and its buttons, and the operator decides what to do next.
const failed = text => {
if (msg) msg.innerHTML = `<span class="vv-ai-bad">${esc(text)}</span>`;
btns.forEach(b => b.disabled = false);
};
fetch(API, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'finding_action', id, act }) })
.then(r => r.json())
.then(d => { if (d.ok) loadFindings(); else failed(d.error || 'failed'); })
.catch(e => failed(String(e)));
});
$('vv-ai-fnd-all').addEventListener('click', () => {
fndAll = !fndAll;
$('vv-ai-fnd-all').textContent = fndAll ? 'Open only' : 'Show closed';
loadFindings();
});
// ── Token accounting ──────────────────────────────────────────────────── // ── Token accounting ────────────────────────────────────────────────────
// Fetched on load and after each completed turn, never on the 30s banner tick: the totals // Fetched on load and after each completed turn, never on the 30s banner tick: the totals
// only move when a turn finishes, and the page is the thing that knows when that was. // only move when a turn finishes, and the page is the thing that knows when that was.
@@ -699,11 +886,16 @@ vv_ai_chat_markup('vv-ai', [
// way, keyed on its prefix, so it is not handled here. // way, keyed on its prefix, so it is not handled here.
if (window.__vvAiTeardown) { try { window.__vvAiTeardown(); } catch (e) {} } if (window.__vvAiTeardown) { try { window.__vvAiTeardown(); } catch (e) {} }
const bannerTimer = setInterval(loadBanner, 30000); const bannerTimer = setInterval(loadBanner, 30000);
window.__vvAiTeardown = function () { clearInterval(bannerTimer); }; // The sweep runs every 15 minutes, so anything faster than this is polling for news that
// cannot have arrived. Five minutes keeps "swept N ago" honest without the card costing
// anything to leave open.
const fndTimer = setInterval(loadFindings, 300000);
window.__vvAiTeardown = function () { clearInterval(bannerTimer); clearInterval(fndTimer); };
loadBanner(); loadBanner();
loadTokens(); loadTokens();
loadBugs(); loadBugs();
loadFindings();
chatList.reload(); chatList.reload();
})(); })();
</script> </script>