Merge commit 'refs/pull/6083/head' of github.com:nesquena/hermes-webui into stage/6083-brick
This commit is contained in:
commit
078a64cabe
5 files changed
+82
-15
No files matched your search
+11
-5
@@ -4056,6 +4056,14 @@ def _session_is_evictable(s) -> bool:
|
||||
on-disk ``message_count`` being at least the in-memory message count.
|
||||
A metadata-only stub is inherently backed by disk, so it is evictable.
|
||||
|
||||
The persistence requirement holds even for a session with ZERO messages.
|
||||
``new_session()`` deliberately does not touch disk until the first message
|
||||
(#1171), so between "New Conversation" and the first send, this cache is the
|
||||
session's ONLY copy. Evicting it does not discard a recreatable empty shell:
|
||||
``get_session()`` has no recreate path and raises ``KeyError``, so the very
|
||||
next ``/api/session/draft`` or ``/api/chat/start`` 404s and the session can
|
||||
never be started. Sessions the user is still composing must stay resident.
|
||||
|
||||
Anything we cannot positively prove is safe stays resident. Using slightly
|
||||
more RAM for a session we are unsure about is strictly better than evicting
|
||||
an active or unsaved session (task safety invariant: a half-done memory fix
|
||||
@@ -4077,14 +4085,12 @@ def _session_is_evictable(s) -> bool:
|
||||
if getattr(s, '_loaded_metadata_only', False):
|
||||
return True
|
||||
in_memory_count = len(getattr(s, 'messages', None) or [])
|
||||
if in_memory_count == 0:
|
||||
# A zero-message session has nothing to lose. If it was never persisted
|
||||
# (brand new, no sidecar) dropping it only discards an empty shell; the
|
||||
# next access recreates it. If it is persisted, it is trivially clean.
|
||||
return True
|
||||
disk_count = _persisted_message_count(sid)
|
||||
if disk_count is None:
|
||||
return False # cannot prove it is on disk → keep it resident
|
||||
if in_memory_count == 0:
|
||||
# Persisted and empty → trivially clean, nothing to lose.
|
||||
return True
|
||||
return disk_count >= in_memory_count
|
||||
|
||||
|
||||
|
||||
+17
-7
@@ -200,7 +200,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-search sidebar-search"><div class="session-search-field"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="sessionSearch" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()" autocomplete="off"><button type="button" id="sessionSearchClear" class="session-search-clear has-tooltip has-tooltip--bottom-right" aria-label="Clear conversation filter" data-tooltip="Clear conversation filter" onclick="clearSessionSearch()" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div></div>
|
||||
<div class="session-search sidebar-search"><div class="session-search-field"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="sessionSearch" type="search" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore="true" data-form-type="other"><button type="button" id="sessionSearchClear" class="session-search-clear has-tooltip has-tooltip--bottom-right" aria-label="Clear conversation filter" data-tooltip="Clear conversation filter" onclick="clearSessionSearch()" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div></div>
|
||||
<div class="session-list" id="sessionList"></div>
|
||||
</div>
|
||||
<!-- Tasks (cron) panel -->
|
||||
@@ -225,7 +225,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="kanban-filter-stack">
|
||||
<div class="sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="kanbanSearch" placeholder="Search tasks" data-i18n-placeholder="kanban_search_tasks" oninput="filterKanban()"></div>
|
||||
<div class="sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="kanbanSearch" type="search" placeholder="Search tasks" data-i18n-placeholder="kanban_search_tasks" oninput="filterKanban()" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore="true" data-form-type="other"></div>
|
||||
<select id="kanbanAssigneeFilter" onchange="loadKanban(true)" aria-label="Assignee filter"></select>
|
||||
<select id="kanbanTenantFilter" onchange="loadKanban(true)" aria-label="Tenant filter"></select>
|
||||
<label class="kanban-check"><input id="kanbanIncludeArchived" type="checkbox" onchange="loadKanban(true)"> <span data-i18n="kanban_include_archived">Include archived</span></label>
|
||||
@@ -253,7 +253,7 @@
|
||||
<button class="panel-head-btn has-tooltip has-tooltip--bottom" onclick="openSkillCreate()" data-tooltip="New skill" data-i18n-title="new_skill" aria-label="New skill"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skills-search sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="skillsSearch" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()"></div>
|
||||
<div class="skills-search sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="skillsSearch" type="search" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore="true" data-form-type="other"></div>
|
||||
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Memory panel -->
|
||||
@@ -353,8 +353,9 @@
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="M21 21l-4.35-4.35"/>
|
||||
</svg>
|
||||
<input id="settingsSearch" data-i18n-placeholder="settings_search_placeholder"
|
||||
placeholder="Search settings…" oninput="filterSettings(this.value)" autocomplete="off">
|
||||
<input id="settingsSearch" type="search" data-i18n-placeholder="settings_search_placeholder"
|
||||
placeholder="Search settings…" oninput="filterSettings(this.value)" autocomplete="off"
|
||||
data-1p-ignore data-lpignore="true" data-bwignore="true" data-form-type="other">
|
||||
<div id="settingsSearchResults" class="settings-search-results" style="display:none"></div>
|
||||
</div>
|
||||
<div class="settings-menu-items">
|
||||
@@ -1541,11 +1542,20 @@
|
||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||
<div id="settingsAuthStatus" style="margin-bottom:8px;display:none"></div>
|
||||
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
<!-- These password inputs need an explicit <form> owner. Without one they are
|
||||
"unowned" fields, so Chromium groups every field in the document into a
|
||||
single synthetic password form and picks the first text input in the DOM
|
||||
(the sidebar conversation filter) as the form's username field — which it
|
||||
then autofills with the saved account name. display:contents keeps the
|
||||
layout byte-identical; onsubmit is neutralised because the panel saves via
|
||||
JS, not a form submit. -->
|
||||
<form id="settingsPasswordForm" style="display:contents" autocomplete="off" onsubmit="return false">
|
||||
<input type="password" id="settingsPassword" autocomplete="new-password" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
<div id="settingsCurrentPasswordBlock" style="display:none;margin-top:6px">
|
||||
<label for="settingsCurrentPassword" style="font-size:11px;display:block;margin-bottom:4px" data-i18n="current_password_label">Current Password</label>
|
||||
<input type="password" id="settingsCurrentPassword" placeholder="Enter current password…" data-i18n-placeholder="current_password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
<input type="password" id="settingsCurrentPassword" autocomplete="current-password" placeholder="Enter current password…" data-i18n-placeholder="current_password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
</form>
|
||||
<div id="settingsPasswordEnvLock" data-i18n="password_env_var_locked" style="display:none;margin-top:6px;padding:8px 10px;font-size:11px;color:var(--muted);background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;line-height:1.45">The HERMES_WEBUI_PASSWORD environment variable is currently set and takes precedence. Unset it and restart the server to manage the password from here.</div>
|
||||
</div>
|
||||
<div id="settingsAuthDisabledWarning" style="display:none;margin-top:8px;padding:10px 12px;border-radius:8px;border:1px solid rgba(220,80,80,.35);background:rgba(220,80,80,.08)">
|
||||
|
||||
+10
-2
@@ -197,7 +197,11 @@ function _renderOnboardingApiKeyField(){
|
||||
const labelKey=keyOptional?'onboarding_api_key_label_optional':'onboarding_api_key_label';
|
||||
const placeholderKey=keyOptional?'onboarding_api_key_placeholder_optional':'onboarding_api_key_placeholder';
|
||||
const helpHtml=keyOptional?`<p class="onboarding-copy onboarding-api-key-help">${esc(t('onboarding_api_key_help_keyless')||'')}</p>`:'';
|
||||
return `<label class="onboarding-field" id="onboardingApiKeyField"><span>${t(labelKey)}</span><input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t(placeholderKey)}" oninput="ONBOARDING.form.apiKey=this.value" onblur="_runOnboardingProbe()"></label>${helpHtml}`;
|
||||
// The <form> owner is load-bearing, not decoration: an unowned password input
|
||||
// makes Chromium treat the whole document as one synthetic password form and
|
||||
// autofill the saved account name into the first text input it finds (the
|
||||
// sidebar conversation filter). display:contents keeps the layout unchanged.
|
||||
return `<form style="display:contents" autocomplete="off" onsubmit="return false"><label class="onboarding-field" id="onboardingApiKeyField"><span>${t(labelKey)}</span><input id="onboardingApiKeyInput" type="password" autocomplete="off" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t(placeholderKey)}" oninput="ONBOARDING.form.apiKey=this.value" onblur="_runOnboardingProbe()"></label></form>${helpHtml}`;
|
||||
}
|
||||
|
||||
function _getOnboardingSelectedModel(){
|
||||
@@ -392,11 +396,15 @@ function _renderOnboardingBody(){
|
||||
|
||||
if(key==='password'){
|
||||
_setOnboardingNotice(settings.password_enabled?t('onboarding_notice_password_enabled'):t('onboarding_notice_password_recommended'), settings.password_enabled?'success':'info');
|
||||
// See the api-key field above: the <form> owner keeps this password input from
|
||||
// turning the whole document into one synthetic credential form.
|
||||
body.innerHTML=`
|
||||
<form style="display:contents" autocomplete="off" onsubmit="return false">
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_password_label')}</span>
|
||||
<input id="onboardingPasswordInput" type="password" value="${esc(ONBOARDING.form.password||'')}" placeholder="${t('onboarding_password_placeholder')}" oninput="ONBOARDING.form.password=this.value">
|
||||
<input id="onboardingPasswordInput" type="password" autocomplete="new-password" value="${esc(ONBOARDING.form.password||'')}" placeholder="${t('onboarding_password_placeholder')}" oninput="ONBOARDING.form.password=this.value">
|
||||
</label>
|
||||
</form>
|
||||
<p class="onboarding-copy">${t('onboarding_password_help')}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
+6
-1
@@ -1565,7 +1565,12 @@
|
||||
.session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;overflow-anchor:none;}
|
||||
.sidebar-search{position:relative;padding:8px 12px;flex-shrink:0;}
|
||||
.session-search-field{position:relative;display:flex;align-items:center;width:100%;}
|
||||
.sidebar-search input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px 7px 32px;font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s;box-sizing:border-box;}
|
||||
.sidebar-search input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px 7px 32px;font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s;box-sizing:border-box;-webkit-appearance:none;appearance:none;}
|
||||
/* The search inputs are type="search" so password managers don't mistake them
|
||||
for username fields. Suppress WebKit's native clear affordance: these fields
|
||||
ship their own clear button (e.g. #sessionSearchClear). */
|
||||
.sidebar-search input::-webkit-search-cancel-button,
|
||||
.sidebar-search input::-webkit-search-decoration{-webkit-appearance:none;appearance:none;display:none;}
|
||||
.session-search input{padding-right:34px;}
|
||||
.sidebar-search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.sidebar-search input::placeholder{color:var(--muted);opacity:.7;}
|
||||
|
||||
@@ -333,3 +333,41 @@ def test_eviction_skips_active_but_still_bounds_clean_entries(isolated_session_e
|
||||
# It may briefly sit slightly above cap because actives are non-evictable,
|
||||
# but it must NOT grow unbounded with the 40 churned sessions.
|
||||
assert len(SESSIONS) <= _cfg.SESSIONS_MAX + len(actives)
|
||||
|
||||
|
||||
def test_unsaved_new_session_survives_churn_and_stays_startable(isolated_session_env):
|
||||
"""A brand-new, never-persisted session must not be evicted.
|
||||
|
||||
``new_session()`` keeps a session in RAM only until its first message
|
||||
(#1171), so the cache is its ONLY copy. The original #4765 predicate treated
|
||||
any zero-message session as evictable, reasoning that an empty shell "is
|
||||
recreated on next access" — but ``get_session()`` has no recreate path and
|
||||
raises ``KeyError``, so ``/api/session/draft`` and ``/api/chat/start`` both
|
||||
404 and the session can never be started.
|
||||
|
||||
Real-world trigger: a browser password manager autofilled the sidebar
|
||||
conversation filter, whose content search pulls every hit through
|
||||
``get_session()``. That churn blew past the cap and dropped the session the
|
||||
user was composing in.
|
||||
"""
|
||||
from api import config as _cfg
|
||||
from api.config import SESSIONS
|
||||
from api.models import get_session, new_session
|
||||
|
||||
_cfg.SESSIONS_MAX = 5
|
||||
|
||||
composing = new_session()
|
||||
sid = composing.session_id
|
||||
assert not (_cfg.SESSION_DIR / f"{sid}.json").exists(), (
|
||||
"precondition: new_session() must not persist before the first message"
|
||||
)
|
||||
|
||||
# Content-search-style churn: far more persisted sessions than the cap.
|
||||
for i in range(40):
|
||||
_insert(_make_persisted_session(i))
|
||||
|
||||
assert sid in SESSIONS, "unsaved new session was evicted — its only copy is gone"
|
||||
|
||||
# The chokepoint both failing routes go through.
|
||||
assert get_session(sid, metadata_only=True) is not None
|
||||
assert get_session(sid).session_id == sid
|
||||
Reference in new issue
Block a user