fix(logpanel): preserve warnings/errors when cap prunes the log (#4900)

* fix(logpanel): preserve warnings/errors when cap prunes the log

When the Research Logs panel hits its 500-entry DOM cap, the previous
implementation dropped entries blindly from the head of the DOM
(oldest-first). On long research runs that meant old warnings and
errors were the first to be flushed, even when the cap was blown by a
flood of routine info entries — exactly the opposite of what a user
navigating to a stale error message would want.

The prune now walks the cap-excess slots in priority order:
  1. info entries (least diagnostic)
  2. milestone entries
  3. warning entries
  4. error entries (most diagnostic — last to be dropped)

Implementation: a new pruneToCap(container, cap) helper is shared by
both prune paths (batch load in loadLogsForResearch and live insert in
addLogEntryToPanel). It re-queries the children NodeList each loop so
the removal doesn't iterate stale entries. The helper is exposed as
window.logPanel._pruneToCap for unit tests, which exercise the
ordering invariants across all four categories.

Tests: 5 new tests under 'pruneToCap — per-category ordered prune'
cover the priority order, the 'info-flood' scenario where 1000 info
entries must not displace older warnings/errors, and the all-warnings-
or-errors fallback path. All 744 JS tests pass.

* fix(changelog): add trailing newline to end of changelog fragment

* fix(logpanel): harden the prune helper and integrate it with per-category counters

Follow-up addressing the AI code review on #4900 and the merge
conflicts produced by #4898 and #4901:

- pruneToCap now filters on the .ldr-console-log-entry class so a
  transient .ldr-empty-log-message, .ldr-loading-spinner, or
  .ldr-error-message that briefly co-exists with log entries cannot
  be miscounted or removed during pruning.
- Priority order is hoisted into a frozen PRUNE_REMOVABLE_ORDER
  constant instead of being recreated on every loop iteration.
- Return type simplified from Array<{type: string}> to string[]; the
  addLogEntryToPanel live-insert path now consumes that return value
  directly to decrement the per-category counts tracked by #4898,
  replacing the duplicated indexed loop that walked the entries a
  second time.
- Docstring documents the chronological-gap trade-off (early errors
  survive alongside newer info) so future readers understand why
  surviving entries are not strictly the newest N.
- Tests updated to the string[] shape, plus a new regression test
  that seeds a placeholder child and verifies it survives pruning
  intact alongside targeted entries.

Rebased onto upstream/main to resolve conflicts with #4898's count
state init and #4901's content-dedup-bypass path.

* test(logpanel): drop redundant under-cap test; clarify all-info prune test name

Follow-up to the AI review on #4900:

- The 'returns an empty array and does nothing when the cap already accommodates every log entry' test was a near-duplicate of the existing 'is a no-op when already under the cap' test in the same describe block. Removed.

- The 'prunes the oldest entries when count exceeds MAX_LOG_ENTRIES' test is the all-info case (which is observationally identical to the old head-drop behavior since info is dropped first in the new priority order). Renamed and annotated so future readers understand that per-category prune ordering is exercised by the pruneToCap describe block lower in the file, not by this test.

PR description typo ('Follow-up: #4900 (skip dedup for non-info)' should reference #4901) was fixed via gh pr edit.

---------

Co-authored-by: GitHub Copilot <copilot@github.com>
This commit is contained in:
S
2026-07-08 05:07:17 +05:30
committed by GitHub
parent ea7bbcf175
commit efdf31a1ae
3 changed files with 249 additions and 21 deletions
@@ -0,0 +1 @@
When the Research Logs panel hits its 500-entry DOM cap, the oldest entries are flushed first — which previously meant old warnings and errors got dropped even when the cap was blown by a flood of routine info entries. The prune now walks the cap-excess slots in priority order (info first, then milestones, then warnings, then errors), so the panel keeps its most diagnostic entries even on long research runs.
@@ -477,6 +477,73 @@
return await response.json();
}
/**
* Trim log entries from the container down to `cap`, preferring to drop
* the least-actionable categories first.
*
* The plain "remove from head" prune loses the panel's most diagnostic
* entries — old warnings and errors get flushed even when the cap was
* blown by a flood of routine info entries. Walk the DOM and, for each
* excess slot, drop the oldest entry whose type is in the currently-
* removable category (info, then milestone). Once those are exhausted,
* fall back to dropping warnings, then errors, in age order.
*
* Trade-off: ordered pruning means surviving entries are no longer
* strictly the newest N — an early error can sit among hundreds of
* newer info entries. That is intentional: warnings/errors are the
* most diagnostic categories and should outlive routine info spam
* during a long research run.
*
* Only `.ldr-console-log-entry` children are pruned; transient
* placeholders such as `.ldr-empty-log-message`,
* `.ldr-loading-spinner`, and `.ldr-error-message` (which can
* briefly co-exist with log entries during async loads) are left
* alone.
*
* @param {Element} container - The log container element.
* @param {number} cap - The maximum allowed entry count after pruning.
* @returns {string[]} The categories of removed entries, in removal
* order. Each element corresponds to one DOM removal. Callers can
* consume the return value to keep an external per-category counter
* in sync without re-querying the DOM.
*/
const PRUNE_REMOVABLE_ORDER = Object.freeze(['info', 'milestone', 'warning', 'error']);
function pruneToCap(container, cap) {
const removed = [];
while (true) {
// Re-query every iteration: each remove() mutates the live
// NodeList, so a cached handle would go stale.
const entries = container.querySelectorAll('.ldr-console-log-entry');
if (entries.length <= cap) break;
let dropped = null;
for (const targetType of PRUNE_REMOVABLE_ORDER) {
for (const entry of entries) {
const type = (entry.dataset.logType || 'info').toLowerCase();
if (type === targetType) {
dropped = entry;
break;
}
}
if (dropped) break;
}
// Defensive fallback — should be unreachable because every log
// entry is created via createLogEntryElement which sets
// dataset.logType, but if some non-log child slipped through
// somehow, drop the oldest of those to guarantee forward
// progress.
if (!dropped) {
const fallback = entries[0];
if (!fallback) break;
removed.push((fallback.dataset.logType || 'info').toLowerCase());
fallback.remove();
continue;
}
removed.push(dropped.dataset.logType.toLowerCase());
dropped.remove();
}
return removed;
}
/**
* Load logs for a specific research
* @param {string} researchId - The research ID to load logs for
@@ -700,10 +767,10 @@
}
logContent.appendChild(fragment);
// Prune oldest entries (at DOM start, since oldest-first ordering)
while (logContent.children.length > MAX_LOG_ENTRIES) {
logContent.firstElementChild.remove();
}
// Prune to the cap, preferring to drop info/milestone entries over
// warnings/errors so the panel keeps its diagnostic entries
// even after a long research run flushes the head.
pruneToCap(logContent, MAX_LOG_ENTRIES);
// Reset and recompute per-category counts from the DOM
// after the batch insert + prune. Counting off the DOM
@@ -1035,23 +1102,22 @@
}
// Prune oldest entries if over limit to prevent unbounded DOM growth.
// DOM order is oldest -> newest, so the oldest entries sit at the
// head of the NodeList.
const entries = consoleLogContainer.querySelectorAll('.ldr-console-log-entry');
if (entries.length > MAX_LOG_ENTRIES) {
const toRemove = entries.length - MAX_LOG_ENTRIES;
for (let i = 0; i < toRemove; i++) {
// Read the log type before removing so we can decrement
// the matching per-category count for the per-filter
// badges. Unknown types default to 'info' to match the
// createLogEntryElement fallback below.
const prunedType = (entries[i].dataset.logType || 'info').toLowerCase();
entries[i].remove();
// Prefer to drop the least-actionable categories first (info, then
// milestones, then warnings, then errors) so a long research run
// doesn't flush the panel's diagnostic tail. Mirrors the batch-load
// prune above.
const removed = pruneToCap(consoleLogContainer, MAX_LOG_ENTRIES);
if (removed.length > 0) {
// Keep the per-category counter for the filter badges in sync
// with what was actually removed. pruneToCap returns the
// categories in removal order, so we can decrement directly
// without re-querying the DOM for each entry's type.
for (const prunedType of removed) {
if (window._logPanelState.counts[prunedType] !== undefined) {
window._logPanelState.counts[prunedType]--;
}
}
updateLogCounter(-toRemove);
updateLogCounter(-removed.length);
updateFilterCounters();
}
@@ -1222,7 +1288,10 @@
initialize: initializeLogPanel,
addLog: addConsoleLog,
filterLogs: filterLogsByType,
loadLogs: loadLogsForResearch
loadLogs: loadLogsForResearch,
// Exposed for unit tests so the per-category prune ordering can be
// exercised in isolation from the rest of the panel pipeline.
_pruneToCap: pruneToCap
};
// Self-invoke to initialize when DOM content is loaded
+161 -3
View File
@@ -142,11 +142,14 @@ function setupPanelDom({ page = 'progress', researchId } = {}) {
logPanel.initialize(rid);
}
function makeLiveEntry(message) {
// Mimic what addLogEntryToPanel produces in the DOM.
function makeLiveEntry(message, type = 'info') {
// Mimic what addLogEntryToPanel produces in the DOM. The optional
// `type` argument lets the per-category prune tests seed entries
// of a specific category into the container directly.
const entry = document.createElement('div');
entry.className = 'ldr-console-log-entry';
entry.dataset.logId = `live-${message}`;
entry.dataset.logType = type;
const span = document.createElement('span');
span.className = 'ldr-log-message';
span.textContent = message;
@@ -431,7 +434,14 @@ describe('addLog / loadLogs — ordering invariants', () => {
// it while the timer bug was still the root cause. The test must fill
// to the real MAX_LOG_ENTRIES cap to exercise the prune, so the work
// can't be reduced without weakening the assertion.
it('prunes the oldest entries when count exceeds MAX_LOG_ENTRIES', { timeout: 20000 }, () => {
// Note: this is the all-info case. With all-info inserts the new
// per-category prune ordering is observationally identical to the
// old head-drop behavior (info is dropped first, so the oldest info
// entry is what comes off the head). Per-category prune ordering --
// the part the priority order actually changes -- is exercised by
// the `pruneToCap -- per-category ordered prune` describe block
// lower in this file.
it('drops the oldest info entry when the cap is exceeded by info-only inserts', { timeout: 20000 }, () => {
const container = document.getElementById('console-log-container');
// One insert over the cap. The live-insert prune in
@@ -1003,3 +1013,151 @@ describe('per-category counters', () => {
expect(getFilterCount('all')).toBe(4);
});
});
describe('pruneToCap — per-category ordered prune', () => {
function seedEntry(container, message, type, index) {
const entry = makeLiveEntry(message, type);
// logpanel.js sorts by dataset.logTimeMs for chronological ordering.
// We use a synthetic timestamp so insertion order matches DOM order.
entry.dataset.logTimeMs = String(index);
container.appendChild(entry);
return entry;
}
it('drops info entries before any other category', () => {
const container = document.getElementById('console-log-container');
// 3 info + 1 warning + 1 error; cap=2.
seedEntry(container, 'info-0', 'info', 0);
seedEntry(container, 'info-1', 'info', 1);
seedEntry(container, 'info-2', 'info', 2);
seedEntry(container, 'warn-0', 'warning', 3);
seedEntry(container, 'err-0', 'error', 4);
const removed = logPanel._pruneToCap(container, 2);
// 3 removals needed (5 -> 2). All should be info entries.
expect(removed.length).toBe(3);
for (const r of removed) expect(r).toBe('info');
expect(container.children.length).toBe(2);
// The warning and error must survive.
const survivingTypes = Array.from(container.children).map(
(c) => c.dataset.logType
);
expect(survivingTypes).toEqual(['warning', 'error']);
});
it('drops milestone entries after info is exhausted', () => {
const container = document.getElementById('console-log-container');
seedEntry(container, 'info-0', 'info', 0);
seedEntry(container, 'info-1', 'info', 1);
seedEntry(container, 'milestone-0', 'milestone', 2);
seedEntry(container, 'warn-0', 'warning', 3);
seedEntry(container, 'err-0', 'error', 4);
// cap=2 -> need 3 removals. 2 info first, then the milestone.
const removed = logPanel._pruneToCap(container, 2);
expect(removed).toEqual(['info', 'info', 'milestone']);
expect(container.children.length).toBe(2);
const surviving = Array.from(container.children).map(
(c) => c.dataset.logType
);
expect(surviving).toEqual(['warning', 'error']);
});
it('preserves old warnings and errors even when the cap is blown by a flood of info', () => {
const container = document.getElementById('console-log-container');
// 1 early error, 1 early warning, then 1000 info entries.
seedEntry(container, 'early-error', 'error', 0);
seedEntry(container, 'early-warning', 'warning', 1);
for (let i = 0; i < 1000; i++) {
seedEntry(container, `info-${i}`, 'info', 2 + i);
}
// cap=200. We need to drop 1002 - 200 = 802 entries. The two
// diagnostic entries are the oldest, but the ordered prune must
// protect them: all 802 drops must be info entries.
const removed = logPanel._pruneToCap(container, 200);
expect(removed.length).toBe(802);
expect(removed.every((r) => r === 'info')).toBe(true);
expect(container.children.length).toBe(200);
const survivingMessages = Array.from(
container.querySelectorAll('.ldr-log-message')
).map((el) => el.textContent);
expect(survivingMessages).toContain('early-error');
expect(survivingMessages).toContain('early-warning');
});
it('falls back to dropping warnings then errors when no info/milestone are left', () => {
const container = document.getElementById('console-log-container');
seedEntry(container, 'warn-0', 'warning', 0);
seedEntry(container, 'warn-1', 'warning', 1);
seedEntry(container, 'err-0', 'error', 2);
// cap=1 -> need 2 removals. No info/milestone, so fall back to
// dropping the oldest warning first, then the next oldest
// warning, before touching the error. Errors are the most
// diagnostic category, so they're the last to be dropped.
const removed = logPanel._pruneToCap(container, 1);
expect(removed).toEqual(['warning', 'warning']);
expect(container.children.length).toBe(1);
expect(container.firstElementChild.dataset.logType).toBe('error');
expect(
container.firstElementChild.querySelector('.ldr-log-message')
.textContent
).toBe('err-0');
});
it('is a no-op when already under the cap', () => {
const container = document.getElementById('console-log-container');
seedEntry(container, 'info-0', 'info', 0);
seedEntry(container, 'err-0', 'error', 1);
const removed = logPanel._pruneToCap(container, 10);
expect(removed).toEqual([]);
expect(container.children.length).toBe(2);
});
it('ignores placeholder children (.ldr-empty-log-message / spinner / error) when pruning', () => {
const container = document.getElementById('console-log-container');
// Simulate the loadLogsForResearch moment where a transient
// spinner is still in the container alongside incoming entries.
const spinner = document.createElement('div');
spinner.className = 'ldr-loading-spinner';
spinner.textContent = 'Loading...';
container.appendChild(spinner);
// Seed one of every category so each priority bucket is exercised.
seedEntry(container, 'info-A', 'info', 0);
seedEntry(container, 'milestone-A', 'milestone', 1);
seedEntry(container, 'warn-A', 'warning', 2);
seedEntry(container, 'err-A', 'error', 3);
// Cap of 2 means 2 of the 4 log entries must be dropped.
// The helper must walk the priority order (info, milestone,
// warning, error) AND must NOT count or remove the placeholder.
// If it had counted the spinner, only one entry would be
// dropped and the test would fail on .removed.length.
const removed = logPanel._pruneToCap(container, 2);
expect(removed).toEqual(['info', 'milestone']);
// Placeholder is left in place; entry nodes are kept too.
expect(container.querySelector('.ldr-loading-spinner')).toBe(spinner);
const survivingLogEntries =
container.querySelectorAll('.ldr-console-log-entry');
expect(survivingLogEntries.length).toBe(2);
expect(survivingLogEntries[0].dataset.logType).toBe('warning');
expect(survivingLogEntries[1].dataset.logType).toBe('error');
// Total DOM children = surviving 2 entries + 1 placeholder.
expect(container.children.length).toBe(3);
expect(container.children[0]).toBe(spinner);
});
// The "no-op when already under the cap" case is covered by
// `it('is a no-op when already under the cap', ...)` above; no
// need to duplicate it here.
});