ouroboros/web/modules/logs.js
Ouroboros 3b36200880 Retire the bg-consciousness special cases from the UI and the gateway
A wake-up is an ordinary direct turn with its own task id, its own durable
result and its own card, so the pseudo task id "bg-consciousness" no longer
names anything live. Every special case built around it goes:

* chat_activity.js loses isBackgroundTaskId/shouldAlwaysShowTaskCard (the
  block appears under the same predicate as any other turn) and the
  "Background thinking" meta hardcode; the P1 origin label is the only one
  left. task_control_menu.js drops the id from REUSABLE_TASK_IDS, so a wake
  offers Stop and is scanned like any other unconfirmed card.
* model_wait.js loses syncBackground, the background owner, the paused phase
  and the "this wakeup cycle" wording; a wake's model wait is a direct turn's
  model wait. The consciousness role label stays (the model slot is honored).
* logs.js and log_events.js file and label a wake by `initiator` only; the
  end-of-cycle `consciousness_state` projector and the "thought"/"background"
  vocabulary are gone, as is the dead `source === 'consciousness'` own-loop
  branch.
* gateway/history.py no longer stamps the loop's last progress row "done" and
  no longer appends its live model-wait row. The retired loop wrote no
  task_result, so its legacy rows replay as any other row whose result is
  gone: the client's durable task-detail read settles the card as "Outcome
  unavailable", never as Done. The dead get_background_model_wait reader and
  the `background` parameter go with it.

The remaining stale mentions (a compacted context section, a context-health
error type, docstrings and comments) are corrected in place.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-16 06:13:31 +03:00

454 lines
20 KiB
JavaScript

import { escapeHtml } from './utils.js';
import { apiFetch } from './api_client.js';
import { setInlineStatus } from './ui_helpers.js';
import { harnessIdentityMarkup } from './harness_presentation.js';
import {
LOG_CATEGORIES,
categorizeLogEvent,
duplicateLogEventKey,
executorChip,
formatReviewProjection,
getLogTaskGroupId,
isGroupedTaskEvent,
normalizeLogTs,
prettyLogEvent,
summarizeLogEvent,
} from './log_events.js';
export function initLogs({ ws, state, mount }) {
const MAX_LOGS = 500;
const MAX_TASK_EVENTS = 30;
const duplicateWindowMs = 5000;
const duplicateState = new Map();
const taskGroups = new Map();
// Exact-duplicate guard so a backfilled row and its live-stream twin (or a
// reconnect re-backfill) render once. Bounded; a rare full clear briefly
// disables the guard, which at worst re-shows one row — never drops one.
const renderedLogKeys = new Set();
let backfillRevision = 0;
state.activeFilters = state.activeFilters || Object.fromEntries(
Object.keys(LOG_CATEGORIES).map((key) => [key, true]),
);
const page = document.createElement('div');
page.id = 'page-logs';
page.className = 'settings-embedded-content settings-logs-panel';
page.innerHTML = `
<div class="logs-filters" id="log-filters"><button class="btn btn-default logs-inline-clear" id="btn-clear-logs">Clear</button></div>
<div class="ui-status logs-history-status" role="status"></div>
<div id="log-entries"></div>
`;
mount.appendChild(page);
const filtersDiv = page.querySelector('#log-filters');
const logEntries = page.querySelector('#log-entries');
const historyStatus = page.querySelector('.logs-history-status');
function isLogsVisible() {
return state.activePage === 'dashboard' && state.dashboardActiveSubtab === 'logs';
}
function scrollToLatest() {
if (!isLogsVisible()) return;
requestAnimationFrame(() => {
logEntries.scrollTop = logEntries.scrollHeight;
});
}
// Live entries must not yank the view away from history the reader
// scrolled up to: per-entry autoscroll sticks only while the reader is
// already at the bottom. Callers capture this BEFORE appending — the
// append itself grows scrollHeight, which would misread a pinned reader
// as scrolled-up. Deliberate jumps (filter toggle, tab switch, backfill)
// stay unconditional. A hidden panel measures 0/0/0 → pinned, matching
// the tab-switch jump the reader gets on return.
const PINNED_SLACK_PX = 48;
function isPinnedToLatest() {
return logEntries.scrollHeight - logEntries.scrollTop - logEntries.clientHeight <= PINNED_SLACK_PX;
}
function updateVisibility(entry) {
entry.hidden = !state.activeFilters[entry.dataset.category];
}
function renderFilters() {
const inlineClear = filtersDiv.querySelector('.logs-inline-clear');
filtersDiv.innerHTML = '';
Object.entries(LOG_CATEGORIES).forEach(([key, cat]) => {
const chip = document.createElement('button');
chip.className = `filter-chip ${state.activeFilters[key] ? 'active' : ''}`;
chip.textContent = cat.label;
chip.addEventListener('click', () => {
state.activeFilters[key] = !state.activeFilters[key];
chip.classList.toggle('active');
logEntries.querySelectorAll('.log-entry').forEach(updateVisibility);
scrollToLatest();
});
filtersDiv.appendChild(chip);
});
if (inlineClear) filtersDiv.appendChild(inlineClear);
}
function trimEntries() {
while (logEntries.children.length > MAX_LOGS) {
const first = logEntries.firstElementChild;
if (!first) break;
const removeKey = [...duplicateState.entries()].find(([, tracked]) => tracked.entry === first)?.[0];
if (removeKey) duplicateState.delete(removeKey);
if (first.dataset.taskGroup) taskGroups.delete(first.dataset.taskGroup);
first.remove();
}
}
function metaPills(meta) {
if (!meta.length) return '';
return `<div class="log-meta">${meta.map((item) => `<span class="log-pill">${escapeHtml(item)}</span>`).join('')}</div>`;
}
function logMainHtml({ ts = '', type = '', phase = 'info', headline = 'Event', repeat = '', attrs = {} }) {
const attr = (key) => attrs[key] ? ` ${attrs[key]}` : '';
return `
<div class="log-main">
<span class="log-ts"${attr('ts')}>${escapeHtml(ts)}</span>
${type ? `<span class="log-type ${escapeHtml(type.className || '')}"${attr('type')}>${escapeHtml(type.label || '')}</span>` : ''}
<span class="log-phase ${escapeHtml(phase)}"${attr('phase')}>${escapeHtml(phase)}</span>
<span class="log-headline"${attr('headline')}>${escapeHtml(headline)}</span>
<span class="log-repeat"${attr('repeat')}${repeat ? '' : ' hidden'}>${escapeHtml(repeat)}</span>
</div>
`;
}
function bindRawToggle(root) {
root.querySelectorAll('.log-raw-toggle').forEach((rawToggle) => {
if (rawToggle.dataset.bound === '1') return;
const rawEl = rawToggle.parentElement?.nextElementSibling;
if (!rawEl || !rawEl.classList.contains('log-raw')) return;
rawToggle.dataset.bound = '1';
rawToggle.addEventListener('click', () => {
const isHidden = rawEl.hasAttribute('hidden');
if (isHidden) {
rawEl.removeAttribute('hidden');
rawToggle.textContent = 'Hide raw';
} else {
rawEl.setAttribute('hidden', '');
rawToggle.textContent = 'Raw';
}
});
});
}
function createStandaloneEntry(evt) {
const view = summarizeLogEvent(evt);
const execution = executorChip(evt);
const cat = categorizeLogEvent(evt, view);
const dedupeKey = duplicateLogEventKey(evt);
const now = (() => {
const parsed = evt.ts ? Date.parse(evt.ts) : NaN;
return Number.isFinite(parsed) ? parsed : Date.now();
})();
if (dedupeKey) {
let last = duplicateState.get(dedupeKey);
if (last && !logEntries.contains(last.entry)) {
duplicateState.delete(dedupeKey);
last = null;
}
if (last && now - last.ts <= duplicateWindowMs) {
last.count += 1;
last.ts = now;
const repeatEl = last.entry.querySelector('.log-repeat');
if (repeatEl) {
repeatEl.textContent = `x${last.count}`;
repeatEl.hidden = false;
}
const tsEl = last.entry.querySelector('.log-ts');
if (tsEl) tsEl.textContent = normalizeLogTs(evt.ts || evt.timestamp);
const rawEl = last.entry.querySelector('.log-raw');
if (rawEl) rawEl.textContent = prettyLogEvent(evt);
logEntries.appendChild(last.entry);
updateVisibility(last.entry);
return;
}
}
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.dataset.category = cat;
const bodyHtml = view.body
? `<div class="log-body">${escapeHtml(view.body)}</div>`
: '';
entry.innerHTML = `
${logMainHtml({
ts: normalizeLogTs(evt.ts || evt.timestamp),
type: { className: cat, label: view.typeLabel },
phase: view.phase || 'info',
headline: view.headline || 'Event',
})}
${execution ? `<div class="harness-chip log-executor-chip" title="${escapeHtml(execution.title || '')}">${harnessIdentityMarkup(execution.harness, {
label: execution.label || '',
className: 'log-executor-identity',
})}</div>` : ''}
${metaPills(view.meta)}
${bodyHtml}
<div class="log-actions">
<button class="log-raw-toggle" type="button">Raw</button>
</div>
<pre class="log-raw" hidden>${escapeHtml(prettyLogEvent(evt))}</pre>
`;
bindRawToggle(entry);
updateVisibility(entry);
const pinned = isPinnedToLatest();
logEntries.appendChild(entry);
if (dedupeKey) {
duplicateState.set(dedupeKey, { entry, ts: now, count: 1 });
}
trimEntries();
if (state.activeFilters[cat] && pinned) scrollToLatest();
}
function createTaskGroupCard(groupId, category, kindLabel) {
const entry = document.createElement('div');
entry.className = 'log-entry log-task-card';
entry.dataset.category = category;
entry.dataset.taskGroup = groupId;
entry.innerHTML = `
${logMainHtml({
type: { className: category, label: kindLabel },
phase: 'info',
headline: 'Task activity',
attrs: {
ts: 'data-task-ts',
type: 'data-task-kind',
phase: 'data-task-phase',
headline: 'data-task-headline',
repeat: 'data-task-count',
},
})}
<div class="log-task-summary" data-task-summary></div>
<div class="harness-chip log-executor-chip" data-task-executor hidden></div>
<div class="log-body log-task-review" data-task-review hidden></div>
<details class="log-task-details">
<summary>Timeline</summary>
<div class="log-task-timeline" data-task-timeline></div>
</details>
`;
const record = {
entry,
ts: entry.querySelector('[data-task-ts]'),
kind: entry.querySelector('[data-task-kind]'),
phase: entry.querySelector('[data-task-phase]'),
headline: entry.querySelector('[data-task-headline]'),
count: entry.querySelector('[data-task-count]'),
summary: entry.querySelector('[data-task-summary]'),
executor: entry.querySelector('[data-task-executor]'),
review: entry.querySelector('[data-task-review]'),
timeline: entry.querySelector('[data-task-timeline]'),
events: 0,
category,
recent: [],
};
taskGroups.set(groupId, record);
return record;
}
function renderTaskTimeline(record) {
record.timeline.innerHTML = record.recent.map((item) => `
<div class="log-task-event">
${logMainHtml({
ts: item.ts,
phase: item.phase || 'info',
headline: item.headline,
repeat: item.count > 1 ? `x${item.count}` : '',
})}
${metaPills(item.meta)}
${item.body ? `<div class="log-body">${escapeHtml(item.body)}</div>` : ''}
<div class="log-actions">
<button class="log-raw-toggle" type="button">Raw</button>
</div>
<pre class="log-raw" hidden>${escapeHtml(item.raw || '')}</pre>
</div>
`).join('');
bindRawToggle(record.timeline);
}
function updateTaskGroupCard(evt) {
const groupId = getLogTaskGroupId(evt);
if (!groupId) {
createStandaloneEntry(evt);
return;
}
const view = summarizeLogEvent(evt);
const eventCategory = categorizeLogEvent(evt, view);
// A task group stays under Errors once one of its events earned it
// (#323): the next heartbeat must not flip the card back to amber and
// drop it out of the Errors filter while the failure is still in its
// timeline. The phase pill still follows the latest event.
const earlier = taskGroups.get(groupId);
// A wake-up's group is labelled by the origin fact its frames carry
// (sticky once seen).
const wake = evt.initiator === 'consciousness' || Boolean(earlier?.wake);
const category = eventCategory === 'errors' || earlier?.category === 'errors' ? 'errors'
: (wake ? 'consciousness' : 'tasks');
const kindLabel = wake ? 'Consciousness' : `task ${groupId}`;
// Captured before ANY record mutation: an already-mounted card grows
// in place (summary rewrite, review unhide, timeline render) before
// the append below, and that growth alone can push a pinned reader
// past the slack allowance.
const pinned = isPinnedToLatest();
const record = taskGroups.get(groupId) || createTaskGroupCard(groupId, category, kindLabel);
const ts = normalizeLogTs(evt.ts || evt.timestamp);
record.events += 1;
record.category = category;
record.wake = wake;
record.entry.dataset.category = category;
record.ts.textContent = ts;
record.kind.textContent = kindLabel;
record.kind.className = `log-type ${category}`;
record.phase.textContent = view.phase || 'info';
record.phase.className = `log-phase ${view.phase || 'info'}`;
record.headline.textContent = view.headline || 'Task activity';
record.count.textContent = `x${record.events}`;
record.count.hidden = record.events <= 1;
record.summary.innerHTML = metaPills([`task=${groupId}`, ...view.meta]);
const execution = executorChip(evt);
if (execution && record.executor) {
record.executor.title = execution.title || '';
record.executor.innerHTML = harnessIdentityMarkup(execution.harness, {
label: execution.label || '',
className: 'log-executor-identity',
});
record.executor.hidden = false;
}
const reviewDetails = formatReviewProjection(evt.review_projection);
if (reviewDetails && record.review) {
record.review.textContent = reviewDetails;
record.review.hidden = false;
}
const last = record.recent[record.recent.length - 1];
const dedupeKey = duplicateLogEventKey(evt);
if (last && last.dupKey && last.dupKey === dedupeKey) {
last.count += 1;
last.ts = ts;
last.meta = view.meta;
last.body = view.body;
last.raw = prettyLogEvent(evt);
} else {
record.recent.push({
ts,
phase: view.phase || 'info',
headline: view.headline || 'Task event',
meta: view.meta,
body: view.body,
raw: prettyLogEvent(evt),
count: 1,
dupKey: dedupeKey,
});
if (record.recent.length > MAX_TASK_EVENTS) record.recent.shift();
}
renderTaskTimeline(record);
updateVisibility(record.entry);
logEntries.appendChild(record.entry);
trimEntries();
if (state.activeFilters[category] && pinned) scrollToLatest();
}
function addLogEntry(evt) {
const ts = evt?.ts || evt?.timestamp || '';
const type = evt?.type || '';
// Dedupe on the natural identity (timestamp has microseconds, so distinct
// events never collide; rapid same-type repeats differ by ts and still
// reach the x-N collapse below). Only guard when both ts and type exist.
const exactKey = (ts && type)
? `${ts}|${type}|${evt?.task_id || evt?.taskId || ''}|${evt?.execution_id || evt?.round || evt?.tool || ''}`
: '';
if (exactKey && renderedLogKeys.has(exactKey)) return;
if (exactKey) {
renderedLogKeys.add(exactKey);
if (renderedLogKeys.size > 6000) {
// Evict the oldest keys (Set preserves insertion order) instead of
// clearing everything, so a reconnect backfill right after the cap
// is still deduped against recent history.
const it = renderedLogKeys.values();
for (let i = 0; i < 1000; i += 1) renderedLogKeys.delete(it.next().value);
}
}
if (isGroupedTaskEvent(evt)) {
updateTaskGroupCard(evt);
return;
}
createStandaloneEntry(evt);
}
renderFilters();
// Backfill recent history so the panel is not empty after a page load or
// reconnect (the live WS stream only carries events from the current
// connection). All dashboard-relevant streams are merged and replayed
// oldest-first; addLogEntry's exact-duplicate guard collapses any overlap
// with the live stream, so this neither drops the pre-connect window nor
// double-renders the post-connect overlap.
async function backfillRecentLogs() {
const revision = ++backfillRevision;
setInlineStatus(historyStatus, 'Loading recent history…');
historyStatus.hidden = false;
const merged = [];
const failed = [];
for (const name of ['events', 'tools', 'progress', 'supervisor']) {
try {
const resp = await apiFetch(`/api/logs/${name}?limit=150`, { cache: 'no-store' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (!Array.isArray(data?.entries)) throw new Error('History unavailable');
merged.push(...data.entries);
} catch {
failed.push(name);
}
}
const tsOf = (evt) => Date.parse(evt?.ts || evt?.timestamp || '') || 0;
merged.sort((a, b) => tsOf(a) - tsOf(b));
for (const evt of merged) addLogEntry(evt);
if (revision === backfillRevision) {
setInlineStatus(historyStatus, failed.length
? `Recent history is incomplete: ${failed.join(', ')} could not be loaded. Live events are shown as they arrive; reconnect reloads recent history.`
: '', 'error');
historyStatus.hidden = failed.length === 0;
}
scrollToLatest();
}
ws.on('log', (msg) => {
if (msg.data) addLogEntry(msg.data);
});
// Backfill at init and on every (re)connect so history missed while
// disconnected is recovered; the dedupe guard keeps the overlap single.
backfillRecentLogs();
ws.on('open', () => { backfillRecentLogs(); });
page.querySelector('#btn-clear-logs').addEventListener('click', () => {
duplicateState.clear();
taskGroups.clear();
logEntries.innerHTML = '';
});
window.addEventListener('ouro:dashboard-subtab-shown', (event) => {
if (event.detail?.tab === 'logs') scrollToLatest();
});
// A `.page` display round-trip destroys the scroll position (documented in
// chat.js: WebKit re-shows a hidden column at scrollTop=0), which would
// read as scrolled-up and silently disarm the pinned-only autoscroll.
// Returning to the dashboard is the same class of deliberate navigation
// as a subtab switch (evolution.js listens to both events the same way),
// so re-pin on it; this also covers the very first dashboard show, where
// the backfill's own jump ran while the panel was still hidden.
window.addEventListener('ouro:page-shown', (event) => {
if (event.detail?.page === 'dashboard' && state.dashboardActiveSubtab === 'logs') scrollToLatest();
});
}