fix(desktop): make remote-profile sessions first-class (resume, read, rename/archive/delete) (#39894)
* fix(desktop): route remote-profile session reads to the owning remote backend Per-profile remote hosts (#39778) wired the chat/resume socket to a profile's remote backend, but session list + transcript reads still assumed every profile's state.db is a local file the primary can open. For a remote profile the local file is absent or stale, so the IDs the sidebar shows 404 the moment resume runs against the remote -- the "session not found -> new session" bug. Intercept the three session-read GETs in the hermes:api handler and route them to the owning remote backend (which serves its own state.db natively): GET /api/profiles/sessions -> splice each remote profile's real rows in GET /api/sessions/{id}[/messages] -> read from the remote for remote profiles No remote profiles configured -> untouched local fast path. A dead remote contributes nothing rather than breaking the sidebar. Verified end-to-end against a live remote backend: a remote-profile session resumes from remote history and continues on the remote across turns (history grows in place, no new session spawned). * fix(desktop): route remote-profile session mutations + fix unified-list pagination Follow-up to the read-routing fix: make remote-profile sessions fully first-class, not just resumable. Mutations (rename/archive/delete) went through the same hermes:api handler but never carried the owning profile, so they hit the local primary's state.db -- which has no row for a remote session. Deleting/archiving/renaming a remote session silently no-op'd or 404'd, and the row reappeared on next refresh. - hermes.ts: setSessionArchived/deleteSession/renameSession take the owning profile and pass it as request.profile so Electron routes to that profile's backend (matching the read path). Callers now forward session.profile. - main.cjs: generalize the intercept (read -> request) to also reroute DELETE/PATCH on /api/sessions/{id} for remote profiles, stripping the profile param (the remote serves its own state.db; no cross-profile semantics there). - web_server.py: DELETE /api/sessions/{id} gains a profile param for parity with GET/PATCH (local cross-profile delete). Also fix the unified-list merge: it concatenated each remote's page onto the primary's without re-windowing, so a limit=N request could return up to N*(1+remotes) rows and report the primary's (stale) total. Now it over-fetches limit+offset from each remote (from offset 0), re-sorts by recency, re-windows to the page, and recomputes total/profile_totals from the remote counts. Verified live against a remote backend: rename/archive/delete mutate the remote db; page 1 windows to limit, profile_totals reflect remote counts, page 2 has no overlap with page 1. tsc -b clean; connection-config tests pass.
This commit is contained in:
commit
9af54b2f8c
5 changed files with 172 additions and 11 deletions
|
|
@ -3909,6 +3909,34 @@ async function resolveRemoteBackend(profile) {
|
|||
return buildRemoteConnection(config.remote?.url, authMode, token, 'settings')
|
||||
}
|
||||
|
||||
// A remote profile's sessions live on its remote host's state.db, not on a local
|
||||
// file the primary can open — so reads for it must route to the remote backend,
|
||||
// not the local-disk fast path. These three helpers drive that (see
|
||||
// interceptSessionReadForRemote).
|
||||
function profileHasRemoteOverride(profile) {
|
||||
return Boolean(profileRemoteOverride(readDesktopConnectionConfig(), profile))
|
||||
}
|
||||
|
||||
function configuredRemoteProfileNames() {
|
||||
const config = readDesktopConnectionConfig()
|
||||
return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name))
|
||||
}
|
||||
|
||||
// GET a profile's resolved backend (remote pool or local primary), parsed JSON.
|
||||
async function fetchJsonForProfile(profile, path) {
|
||||
return requestJsonForProfile(profile, path, 'GET')
|
||||
}
|
||||
|
||||
// Issue an arbitrary method against a profile's resolved backend, parsed JSON.
|
||||
async function requestJsonForProfile(profile, path, method, body) {
|
||||
const conn = await ensureBackend(profile)
|
||||
const url = `${conn.baseUrl}${path}`
|
||||
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
|
||||
return conn.authMode === 'oauth'
|
||||
? fetchJsonViaOauthSession(url, opts)
|
||||
: fetchJson(url, conn.token, opts)
|
||||
}
|
||||
|
||||
async function probeRemoteAuthMode(rawUrl) {
|
||||
// Determine how a remote gateway expects callers to authenticate, WITHOUT
|
||||
// sending any credentials. ``/api/status`` is public on every Hermes
|
||||
|
|
@ -4698,7 +4726,129 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
|
|||
return systemPreferences.askForMediaAccess('microphone')
|
||||
})
|
||||
|
||||
// Re-route remote-profile session requests to the owning remote backend. Returns
|
||||
// `undefined` when not interceptable (caller takes the normal local path), else
|
||||
// the response. Reads tag the profile as ?profile=<name>; mutations carry it in
|
||||
// request.profile. Either way, a remote profile's session lives only on its
|
||||
// remote host, so the request must go there (where it serves its own state.db).
|
||||
// GET /api/profiles/sessions → splice each remote profile's rows in
|
||||
// GET /api/sessions/{id}[/messages] → read from remote
|
||||
// DELETE /api/sessions/{id} → delete on remote
|
||||
// PATCH /api/sessions/{id} → rename/archive on remote
|
||||
async function interceptSessionRequestForRemote(request) {
|
||||
if (typeof request?.path !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
const method = (request.method || 'GET').toUpperCase()
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(request.path, 'http://x')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
const { pathname, searchParams } = parsed
|
||||
|
||||
if (method === 'GET' && pathname === '/api/profiles/sessions') {
|
||||
const remoteProfiles = configuredRemoteProfileNames()
|
||||
if (remoteProfiles.length === 0) {
|
||||
return undefined // no remote profiles → local fast path
|
||||
}
|
||||
const requested = (searchParams.get('profile') || 'all').trim() || 'all'
|
||||
if (requested !== 'all') {
|
||||
return profileHasRemoteOverride(requested) ? remoteSessionList(requested, searchParams) : undefined
|
||||
}
|
||||
return mergeRemoteProfileSessions(searchParams, remoteProfiles)
|
||||
}
|
||||
|
||||
// Per-session read/mutation. Owner is in ?profile= (reads) or request.profile
|
||||
// (mutations); route to the remote sans profile param — it serves its own
|
||||
// state.db, with no cross-profile semantics.
|
||||
if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) {
|
||||
const profile = (searchParams.get('profile') || request.profile || '').trim()
|
||||
if (!profile || !profileHasRemoteOverride(profile)) {
|
||||
return undefined
|
||||
}
|
||||
if (method === 'GET') {
|
||||
return fetchJsonForProfile(profile, pathname)
|
||||
}
|
||||
const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body
|
||||
if (body) delete body.profile
|
||||
return requestJsonForProfile(profile, pathname, method, body)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const rowsOf = data => (Array.isArray(data?.sessions) ? data.sessions : [])
|
||||
|
||||
// A remote profile's session list, read from its remote host and tagged with the
|
||||
// desktop-facing profile name (the remote's /api/sessions doesn't know it).
|
||||
async function remoteSessionList(profile, searchParams) {
|
||||
const qs = new URLSearchParams(searchParams)
|
||||
qs.delete('profile') // remote serves its own db; no cross-profile read there
|
||||
const data = await fetchJsonForProfile(profile, `/api/sessions?${qs}`)
|
||||
for (const s of rowsOf(data)) {
|
||||
s.profile = profile
|
||||
s.is_default_profile = false
|
||||
}
|
||||
return { ...data, sessions: rowsOf(data) }
|
||||
}
|
||||
|
||||
// Unified list: primary's local aggregate, with each remote profile's stale local
|
||||
// rows/totals swapped for the remote's real ones, re-sorted by recency and
|
||||
// re-windowed to the requested page. A dead remote contributes nothing rather
|
||||
// than breaking the sidebar.
|
||||
async function mergeRemoteProfileSessions(searchParams, remoteProfiles) {
|
||||
const limit = Math.max(1, Number(searchParams.get('limit')) || 20)
|
||||
const offset = Math.max(0, Number(searchParams.get('offset')) || 0)
|
||||
const order = searchParams.get('order') === 'created' ? 'started_at' : 'last_active'
|
||||
|
||||
const primary = await ensureBackend(null)
|
||||
const base = await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, {
|
||||
method: 'GET',
|
||||
timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
|
||||
}).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))
|
||||
|
||||
// Over-fetch each remote from offset 0 (limit+offset rows) so the merged window
|
||||
// is correct for this page — mirrors the primary's per-profile over-fetch.
|
||||
const remoteParams = new URLSearchParams(searchParams)
|
||||
remoteParams.set('limit', String(limit + offset))
|
||||
remoteParams.set('offset', '0')
|
||||
|
||||
const remoteSet = new Set(remoteProfiles)
|
||||
const merged = rowsOf(base).filter(s => !remoteSet.has(s?.profile))
|
||||
const profileTotals = { ...(base.profile_totals || {}) }
|
||||
let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0)
|
||||
|
||||
// Swap each remote profile's stale local rows/total for the remote's real ones.
|
||||
await Promise.all(remoteProfiles.map(async name => {
|
||||
const list = await remoteSessionList(name, remoteParams).catch(() => null)
|
||||
if (!list) {
|
||||
delete profileTotals[name] // dead remote → drop its stale local total too
|
||||
return
|
||||
}
|
||||
const rows = rowsOf(list)
|
||||
merged.push(...rows)
|
||||
profileTotals[name] = Number(list.total) || rows.length
|
||||
total += profileTotals[name]
|
||||
}))
|
||||
|
||||
const recency = s => s?.[order] ?? s?.started_at ?? 0
|
||||
merged.sort((a, b) => recency(b) - recency(a))
|
||||
return { ...base, sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals }
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
// Remote-profile session requests would otherwise hit the local primary off
|
||||
// each profile's on-disk state.db — fine for local profiles, but a remote
|
||||
// profile's sessions live on its remote host, so the UI's IDs 404 (or mutations
|
||||
// no-op) the moment they run there. Route reads + mutations to the remote.
|
||||
const rerouted = await interceptSessionRequestForRemote(request)
|
||||
if (rerouted !== undefined) {
|
||||
return rerouted
|
||||
}
|
||||
|
||||
const connection = await ensureBackend(request?.profile)
|
||||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
const url = `${connection.baseUrl}${request.path}`
|
||||
|
|
|
|||
|
|
@ -763,7 +763,7 @@ export function useSessionActions({
|
|||
await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined)
|
||||
}
|
||||
|
||||
await deleteSession(storedSessionId)
|
||||
await deleteSession(storedSessionId, removed?.profile)
|
||||
clearQueuedPrompts(storedSessionId)
|
||||
|
||||
if (closingRuntimeId) {
|
||||
|
|
@ -839,7 +839,7 @@ export function useSessionActions({
|
|||
}
|
||||
|
||||
try {
|
||||
await setSessionArchived(storedSessionId, true)
|
||||
await setSessionArchived(storedSessionId, true, archived?.profile)
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export function SessionsSettings() {
|
|||
setBusyId(session.id)
|
||||
|
||||
try {
|
||||
await setSessionArchived(session.id, false)
|
||||
await setSessionArchived(session.id, false, session.profile)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
// Surface it again in the sidebar without waiting for a full refresh.
|
||||
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
|
||||
|
|
@ -78,7 +78,7 @@ export function SessionsSettings() {
|
|||
setBusyId(session.id)
|
||||
|
||||
try {
|
||||
await deleteSession(session.id)
|
||||
await deleteSession(session.id, session.profile)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
triggerHaptic('warning')
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -166,8 +166,13 @@ export async function listAllProfileSessions(
|
|||
}
|
||||
}
|
||||
|
||||
export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
|
||||
// Mutations take the owning `profile` so Electron routes them to that profile's
|
||||
// backend (remote pool or local primary) via request.profile — matching the
|
||||
// read path. A remote session's row lives only on its remote host, so a mutation
|
||||
// that hit the local primary would no-op or 404. Omit for the current/default.
|
||||
export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { archived }
|
||||
|
|
@ -180,8 +185,10 @@ export function searchSessions(query: string): Promise<SessionSearchResponse> {
|
|||
})
|
||||
}
|
||||
|
||||
// `profile` reads another profile's transcript straight off its state.db via the
|
||||
// primary backend (no spawn). Omit for the current/default profile.
|
||||
// Reads another profile's transcript. For a remote profile Electron reroutes
|
||||
// this GET to the remote backend (which serves its own state.db); for a local
|
||||
// profile the primary opens that profile's state.db via ?profile=. Omit for
|
||||
// the current/default profile.
|
||||
export function getSessionMessages(id: string, profile?: string | null): Promise<SessionMessagesResponse> {
|
||||
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
|
||||
|
||||
|
|
@ -190,8 +197,9 @@ export function getSessionMessages(id: string, profile?: string | null): Promise
|
|||
})
|
||||
}
|
||||
|
||||
export function deleteSession(id: string): Promise<{ ok: boolean }> {
|
||||
export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
|
@ -203,6 +211,7 @@ export function renameSession(
|
|||
profile?: string | null
|
||||
): Promise<{ ok: boolean; title: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; title: string }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { title, ...(profile ? { profile } : {}) }
|
||||
|
|
|
|||
|
|
@ -5257,9 +5257,11 @@ async def get_session_messages(session_id: str, profile: Optional[str] = None):
|
|||
|
||||
|
||||
@app.delete("/api/sessions/{session_id}")
|
||||
async def delete_session_endpoint(session_id: str):
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
async def delete_session_endpoint(session_id: str, profile: Optional[str] = None):
|
||||
# ``profile`` deletes a session belonging to another (local) profile by
|
||||
# opening its state.db directly. Remote profiles never reach here — the
|
||||
# desktop routes their DELETE to the remote backend. Omit for current/default.
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
if not db.delete_session(session_id):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue