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.
This commit is contained in:
Gmer4Lfe
2026-08-09 21:14:44 -04:00
parent b20a46ba81
commit 77fb1abb85
2 changed files with 257 additions and 7 deletions
+199 -7
View File
@@ -47,17 +47,27 @@
// 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.
//
// Read-only with respect to the system. Nothing here runs a script, edits conf, or changes
// any Varaverk state — it asks questions about documentation.
// The conversation is read-only with respect to the system. The findings card is not.
// 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
// Status banner (index, model residency, GPU, staleness), chat transcript with collapsible
// reasoning and audited sources, composer with retrieval-scope and reasoning controls,
// source viewer overlay
// Status banner (index, model residency, GPU, staleness), repair findings with their actions,
// assistant-filed bug reports, chat transcript with collapsible reasoning and audited sources,
// composer with retrieval-scope and reasoning controls, source viewer overlay
//
// DEPENDS ON
// 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
// ═══════════════════════════════════════════════════════════════════════════════════════════════
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; }
.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 ──────────────────────────────────────────────────────── */
/* 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.
@@ -187,6 +228,25 @@ if (is_dir('/var/log/varaverk')) {
</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:
an empty card here would be a permanent reminder of nothing, and this row already
competes for the space above the transcript. -->
@@ -527,6 +587,133 @@ vv_ai_chat_markup('vv-ai', [
.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 ────────────────────────────────────────────────────
// 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.
@@ -699,11 +886,16 @@ vv_ai_chat_markup('vv-ai', [
// way, keyed on its prefix, so it is not handled here.
if (window.__vvAiTeardown) { try { window.__vvAiTeardown(); } catch (e) {} }
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();
loadTokens();
loadBugs();
loadFindings();
chatList.reload();
})();
</script>