fix session profile listing and cli sqlite warning (#971)
This commit is contained in:
@@ -5,7 +5,6 @@ import { fileURLToPath } from 'url'
|
||||
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, chmodSync, statSync, existsSync, realpathSync } from 'fs'
|
||||
import { randomBytes, scryptSync } from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
@@ -497,9 +496,10 @@ function hashPassword(password) {
|
||||
return `scrypt:${salt}:${hash}`
|
||||
}
|
||||
|
||||
function resetDefaultLogin(options = {}) {
|
||||
async function resetDefaultLogin(options = {}) {
|
||||
const { silent = false } = options
|
||||
mkdirSync(WEB_UI_HOME, { recursive: true })
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const db = new DatabaseSync(WEB_UI_DB_FILE)
|
||||
try {
|
||||
db.exec(`
|
||||
@@ -545,7 +545,7 @@ function resetDefaultLogin(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
async function main() {
|
||||
const command = process.argv[2] || 'start'
|
||||
|
||||
if (['-v', '--version', 'version'].includes(command)) {
|
||||
@@ -604,7 +604,7 @@ Options:
|
||||
break
|
||||
}
|
||||
case 'reset-default-login':
|
||||
resetDefaultLogin()
|
||||
await resetDefaultLogin()
|
||||
break
|
||||
case 'update':
|
||||
case 'upgrade':
|
||||
@@ -690,7 +690,10 @@ function runUpdateInstall(npm) {
|
||||
}
|
||||
|
||||
if (process.argv[1] && realpathSync(resolve(process.argv[1])) === __filename) {
|
||||
main()
|
||||
main().catch(err => {
|
||||
console.error(` ✗ ${err?.message || err}`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -65,12 +65,21 @@ function shouldAttachProfileHeader(path: string, options: RequestInit): boolean
|
||||
const url = new URL(path, 'http://hermes.local')
|
||||
if (url.searchParams.has('profile')) return false
|
||||
if (url.pathname.startsWith('/api/hermes/profiles')) return false
|
||||
if (isProfileWideSessionCollection(url.pathname)) return false
|
||||
} catch {
|
||||
if (path.startsWith('/api/hermes/profiles')) return false
|
||||
if (isProfileWideSessionCollection(path.split('?')[0] || path)) return false
|
||||
}
|
||||
return !bodyHasProfileSelector(options.body)
|
||||
}
|
||||
|
||||
function isProfileWideSessionCollection(pathname: string): boolean {
|
||||
return pathname === '/api/hermes/sessions' ||
|
||||
pathname === '/api/hermes/search/sessions' ||
|
||||
pathname === '/api/hermes/sessions/search' ||
|
||||
pathname === '/api/hermes/sessions/conversations'
|
||||
}
|
||||
|
||||
function emitAuthNotice(kind: 'expired' | 'forbidden') {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new CustomEvent('hermes-auth-notice', { detail: { kind } }))
|
||||
|
||||
@@ -38,6 +38,11 @@ function requestedProfile(ctx: any): string | undefined {
|
||||
return value || undefined
|
||||
}
|
||||
|
||||
function explicitProfileFilter(ctx: any): string | undefined {
|
||||
const value = typeof ctx.query?.profile === 'string' ? ctx.query.profile.trim() : ''
|
||||
return value || undefined
|
||||
}
|
||||
|
||||
function allowedProfileSet(ctx: any): Set<string> | null {
|
||||
const user = ctx.state?.user
|
||||
if (!user || user.role === 'super_admin') return null
|
||||
@@ -103,7 +108,7 @@ export async function listConversations(ctx: any) {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
|
||||
const profile = requestedProfile(ctx)
|
||||
const profile = explicitProfileFilter(ctx)
|
||||
const sessions = localListSessions(profile, source, limit && limit > 0 ? limit : 200)
|
||||
const summaries: ConversationSummary[] = sessions.map(s => ({
|
||||
id: s.id,
|
||||
@@ -168,7 +173,7 @@ export async function getConversationMessages(ctx: any) {
|
||||
export async function list(ctx: any) {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
const profile = requestedProfile(ctx)
|
||||
const profile = explicitProfileFilter(ctx)
|
||||
const effectiveLimit = limit && limit > 0 ? limit : 2000
|
||||
|
||||
const allSessions = localListSessions(profile, source, effectiveLimit)
|
||||
@@ -199,7 +204,7 @@ export async function listHermesSessions(ctx: any) {
|
||||
export async function search(ctx: any) {
|
||||
const q = typeof ctx.query.q === 'string' ? ctx.query.q : ''
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
const profile = requestedProfile(ctx)
|
||||
const profile = explicitProfileFilter(ctx)
|
||||
const results = localSearchSessions(profile, q, limit && limit > 0 ? limit : 20)
|
||||
const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk())
|
||||
ctx.body = {
|
||||
|
||||
@@ -79,12 +79,22 @@ describe('API Client', () => {
|
||||
localStorage.setItem('hermes_active_profile_name', 'default')
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
|
||||
|
||||
await request('/api/hermes/sessions')
|
||||
await request('/api/hermes/sessions/session-1')
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.headers['X-Hermes-Profile']).toBe('default')
|
||||
})
|
||||
|
||||
it('does not add the active profile header to profile-wide session collection requests', async () => {
|
||||
localStorage.setItem('hermes_active_profile_name', 'research')
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
|
||||
|
||||
await request('/api/hermes/sessions')
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.headers['X-Hermes-Profile']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not add Authorization header when no token', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('CLI port detection', () => {
|
||||
|
||||
try {
|
||||
const { resetDefaultLogin } = await loadCli()
|
||||
const created = resetDefaultLogin({ silent: true })
|
||||
const created = await resetDefaultLogin({ silent: true })
|
||||
expect(created.action).toBe('created')
|
||||
|
||||
const db = new DatabaseSync(dbPath)
|
||||
@@ -144,7 +144,7 @@ describe('CLI port detection', () => {
|
||||
db.close()
|
||||
}
|
||||
|
||||
const updated = resetDefaultLogin({ silent: true })
|
||||
const updated = await resetDefaultLogin({ silent: true })
|
||||
expect(updated.action).toBe('updated')
|
||||
|
||||
const verifyDb = new DatabaseSync(dbPath)
|
||||
|
||||
@@ -22,6 +22,7 @@ const getLocalUsageStatsMock = vi.fn()
|
||||
const getActiveProfileNameMock = vi.fn()
|
||||
const loggerWarnMock = vi.fn()
|
||||
const getCompressionSnapshotMock = vi.fn()
|
||||
const listUserProfilesMock = vi.fn()
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/conversations-db', () => ({
|
||||
listConversationSummariesFromDb: listConversationSummariesFromDbMock,
|
||||
@@ -68,6 +69,10 @@ vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
|
||||
updateSession: localUpdateSessionMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
|
||||
listUserProfiles: listUserProfilesMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
|
||||
deleteUsage: vi.fn(),
|
||||
getUsage: vi.fn(),
|
||||
@@ -130,6 +135,8 @@ describe('session conversations controller', () => {
|
||||
getActiveProfileNameMock.mockReturnValue('default')
|
||||
loggerWarnMock.mockReset()
|
||||
getCompressionSnapshotMock.mockReset()
|
||||
listUserProfilesMock.mockReset()
|
||||
listUserProfilesMock.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('lists conversations from the local session store', async () => {
|
||||
@@ -165,6 +172,120 @@ describe('session conversations controller', () => {
|
||||
expect(ctx.body.sessions[0]).toMatchObject({ id: 'local-conversation', source: 'cli', title: 'Local' })
|
||||
})
|
||||
|
||||
it('lists all account-accessible single-chat sessions when only the active profile header is present', async () => {
|
||||
listUserProfilesMock.mockReturnValue([{ profile_name: 'default' }, { profile_name: 'travel' }])
|
||||
localListSessionsMock.mockReturnValue([
|
||||
{
|
||||
id: 'default-session',
|
||||
profile: 'default',
|
||||
source: 'cli',
|
||||
model: 'gpt-5',
|
||||
title: 'Default',
|
||||
started_at: 1,
|
||||
ended_at: null,
|
||||
last_active: 3,
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: null,
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
preview: '',
|
||||
},
|
||||
{
|
||||
id: 'travel-session',
|
||||
profile: 'travel',
|
||||
source: 'cli',
|
||||
model: 'gpt-5',
|
||||
title: 'Travel',
|
||||
started_at: 2,
|
||||
ended_at: null,
|
||||
last_active: 4,
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: null,
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
preview: '',
|
||||
},
|
||||
{
|
||||
id: 'secret-session',
|
||||
profile: 'secret',
|
||||
source: 'cli',
|
||||
model: 'gpt-5',
|
||||
title: 'Secret',
|
||||
started_at: 3,
|
||||
ended_at: null,
|
||||
last_active: 5,
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: null,
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
preview: '',
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||
const ctx: any = {
|
||||
query: {},
|
||||
state: {
|
||||
user: { id: 1, role: 'admin' },
|
||||
profile: { name: 'travel' },
|
||||
},
|
||||
body: null,
|
||||
}
|
||||
await mod.list(ctx)
|
||||
|
||||
expect(localListSessionsMock).toHaveBeenCalledWith(undefined, undefined, 2000)
|
||||
expect(ctx.body.sessions.map((session: any) => session.id)).toEqual(['default-session', 'travel-session'])
|
||||
})
|
||||
|
||||
it('filters the single-chat session list when profile is explicitly provided', async () => {
|
||||
localListSessionsMock.mockReturnValue([])
|
||||
|
||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||
const ctx: any = {
|
||||
query: { profile: 'travel' },
|
||||
state: { profile: { name: 'default' } },
|
||||
body: null,
|
||||
}
|
||||
await mod.list(ctx)
|
||||
|
||||
expect(localListSessionsMock).toHaveBeenCalledWith('travel', undefined, 2000)
|
||||
})
|
||||
|
||||
it('searches all account-accessible single-chat sessions unless profile is explicit', async () => {
|
||||
localSearchSessionsMock.mockReturnValue([])
|
||||
|
||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||
const ctx: any = {
|
||||
query: { q: 'docker', limit: '10' },
|
||||
state: { profile: { name: 'travel' } },
|
||||
body: null,
|
||||
}
|
||||
await mod.search(ctx)
|
||||
|
||||
expect(localSearchSessionsMock).toHaveBeenCalledWith(undefined, 'docker', 10)
|
||||
})
|
||||
|
||||
it('propagates local session store errors for conversation summaries', async () => {
|
||||
localListSessionsMock.mockImplementation(() => {
|
||||
throw new Error('db unavailable')
|
||||
|
||||
Reference in New Issue
Block a user