diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3a2164d3..6752aea4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -164,6 +164,45 @@ export async function bootstrap() { logger.info('Auth enabled — token: %s', authToken) } + // Cache policy + legacy service worker cleanup + const legacyServiceWorkerPaths = new Set([ + '/service-worker.js', + '/sw.js', + '/ngsw-worker.js', + '/workbox-sw.js', + ]) + + app.use(async (ctx, next) => { + if (legacyServiceWorkerPaths.has(ctx.path)) { + ctx.status = 200 + ctx.type = 'application/javascript; charset=utf-8' + ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate') + ctx.set('Pragma', 'no-cache') + ctx.set('Expires', '0') + ctx.body = `self.addEventListener('install',event=>{self.skipWaiting();}); +self.addEventListener('activate',event=>{event.waitUntil((async()=>{const keys=await caches.keys();await Promise.all(keys.map(key=>caches.delete(key)));await self.registration.unregister();const clientsList=await self.clients.matchAll({type:'window',includeUncontrolled:true});for(const client of clientsList)client.navigate(client.url);})());}); +` + return + } + + await next() + + if (ctx.path === '/' || + ctx.path === '/index.html' || + (!ctx.path.startsWith('/api') && + !ctx.path.startsWith('/assets/') && + !ctx.path.startsWith('/upload') && + !ctx.path.startsWith('/socket.io') && + !ctx.path.includes('.'))) { + ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate') + ctx.set('Pragma', 'no-cache') + ctx.set('Expires', '0') + } else if (ctx.path.startsWith('/assets/')) { + ctx.set('Cache-Control', 'public, max-age=31536000, immutable') + } + }) + console.log('[bootstrap] cache-control middleware registered') + // SPA fallback const distDir = resolve(__dirname, '..', 'client') app.use(serve(distDir)) @@ -171,7 +210,8 @@ export async function bootstrap() { if (!ctx.path.startsWith('/api') && ctx.path !== '/health' && ctx.path !== '/upload' && - ctx.path !== '/webhook') { + ctx.path !== '/webhook' && + !legacyServiceWorkerPaths.has(ctx.path)) { await send(ctx, 'index.html', { root: distDir }) } }) diff --git a/packages/server/src/services/hermes/session-sync.ts b/packages/server/src/services/hermes/session-sync.ts index 9ab773d0..4d2fff32 100644 --- a/packages/server/src/services/hermes/session-sync.ts +++ b/packages/server/src/services/hermes/session-sync.ts @@ -1,38 +1,25 @@ /** * Sync Hermes sessions from all profiles on startup. - * Reads api_server sessions from Hermes state.db and imports into local DB. - * Only runs when local DB is empty (first startup). + * Reads webui/api_server sessions from Hermes state.db and imports missing sessions into local DB. + * Runs incrementally and skips sessions that were already mirrored. * * Uses sessions-db.ts query logic to properly aggregate session chains. */ import { readdirSync, existsSync } from 'fs' import { resolve, join } from 'path' import { homedir } from 'os' -import { randomBytes } from 'crypto' import { getProfileDir } from './hermes-profile' -import { createSession, addMessage, updateSession } from '../../db/hermes/session-store' +import { createSession, addMessage, updateSession, getSession } from '../../db/hermes/session-store' import { getDb } from '../../db/index' import { logger } from '../logger' -import { listSessionSummaries as listHermesSessionSummaries } from '../../db/hermes/sessions-db' +import { + getSessionDetailFromDbWithProfile, + listSessionSummaries as listHermesSessionSummaries, +} from '../../db/hermes/sessions-db' const HERMES_BASE = resolve(homedir(), '.hermes') const PROFILES_DIR = join(HERMES_BASE, 'profiles') - -/** - * Generate a UUID v4 without external dependencies - */ -function generateUuid(): string { - const bytes = randomBytes(16) - bytes[6] = (bytes[6]! & 0x0f) | 0x40 // Version 4 - bytes[8] = (bytes[8]! & 0x3f) | 0x80 // Variant 10 - return [ - bytes.subarray(0, 4).toString('hex'), - bytes.subarray(4, 6).toString('hex'), - bytes.subarray(6, 8).toString('hex'), - bytes.subarray(8, 10).toString('hex'), - bytes.subarray(10, 16).toString('hex'), - ].join('-') -} +const SYNC_SOURCES = ['webui', 'api_server'] as const /** * Get all available profile names including 'default' @@ -50,93 +37,128 @@ function getAllProfiles(): string[] { return profiles } +function hasMatchingLegacyImport(profile: string, hermesSession: { source: string; started_at: number; title: string | null }): boolean { + const db = getDb() + if (!db) return false + + const row = db.prepare( + `SELECT id FROM sessions + WHERE profile = ? + AND source = ? + AND started_at = ? + AND COALESCE(title, '') = COALESCE(?, '') + LIMIT 1`, + ).get(profile, hermesSession.source, hermesSession.started_at, hermesSession.title) as { id: string } | undefined + + return !!row +} + +function isAlreadySynced(profile: string, localSessionId: string, hermesSession: { source: string; started_at: number; title: string | null }): boolean { + return !!getSession(localSessionId) || hasMatchingLegacyImport(profile, hermesSession) +} + /** - * Sync api_server sessions from a single profile. + * Sync configured Hermes session sources from a single profile. * Uses sessions-db.ts query logic to properly aggregate session chains. */ async function syncProfileSessions(profile: string): Promise<{ synced: number + skipped: number errors: string[] }> { - const result = { synced: 0, errors: [] as string[] } - - try { - // Use listSessionSummaries to get aggregated session chains - // This returns only root sessions with aggregated stats from the entire chain - const summaries = await listHermesSessionSummaries('api_server', 10000, profile) - - logger.info(`[session-sync] profile '${profile}': found ${summaries.length} aggregated session chains`) - - for (const hermesSession of summaries) { - // Skip ephemeral sessions (created internally by chat-run-socket) - if (hermesSession.id.startsWith('eph_')) continue - try { - // Generate new session ID for local DB - const newSessionId = generateUuid() - - // Create session in local DB - createSession({ - id: newSessionId, - profile, - model: hermesSession.model, - title: hermesSession.title || undefined, - }) - - // Get full detail including all messages from the session chain - const { getSessionDetailFromDbWithProfile } = await import('../../db/hermes/sessions-db') - const detail = await getSessionDetailFromDbWithProfile(hermesSession.id, profile) - - if (!detail || !detail.messages) { - result.errors.push(`session ${hermesSession.id}: failed to load messages`) - logger.warn(`[session-sync] failed to load messages for session ${hermesSession.id}`) - continue - } + const result = { synced: 0, skipped: 0, errors: [] as string[] } + + for (const source of SYNC_SOURCES) { + try { + // Use listSessionSummaries to get aggregated session chains. + // This returns only root sessions with aggregated stats from the entire chain. + const summaries = await listHermesSessionSummaries(source, 10000, profile) + + logger.info(`[session-sync] profile '${profile}', source '${source}': found ${summaries.length} aggregated session chains`) + + for (const hermesSession of summaries) { + // Skip ephemeral sessions (created internally by chat-run-socket) + if (hermesSession.id.startsWith('eph_')) continue + + try { + // Use Hermes' stable session id in the local mirror so startup sync is idempotent. + const localSessionId = hermesSession.id + + if (isAlreadySynced(profile, localSessionId, hermesSession)) { + result.skipped++ + continue + } + + // Get full detail including all messages from the session chain before mutating the mirror. + const detail = await getSessionDetailFromDbWithProfile(hermesSession.id, profile) + + if (!detail || !detail.messages) { + result.errors.push(`session ${hermesSession.id}: failed to load messages`) + logger.warn(`[session-sync] failed to load messages for session ${hermesSession.id}`) + continue + } + + // Create session in local DB + createSession({ + id: localSessionId, + profile, + model: hermesSession.model, + title: hermesSession.title || undefined, + }) - // Insert all messages from the entire chain - for (const msg of detail.messages) { - addMessage({ - session_id: newSessionId, - role: msg.role, - content: msg.content, - tool_call_id: msg.tool_call_id, - tool_calls: msg.tool_calls, - tool_name: msg.tool_name, - timestamp: msg.timestamp, - token_count: msg.token_count, - finish_reason: msg.finish_reason, - reasoning: msg.reasoning, - reasoning_details: msg.reasoning_details, - reasoning_content: msg.reasoning_content, - codex_reasoning_items: msg.codex_reasoning_items, + // Insert all messages from the entire chain + for (const msg of detail.messages) { + addMessage({ + session_id: localSessionId, + role: msg.role, + content: msg.content, + tool_call_id: msg.tool_call_id, + tool_calls: msg.tool_calls, + tool_name: msg.tool_name, + timestamp: msg.timestamp, + token_count: msg.token_count, + finish_reason: msg.finish_reason, + reasoning: msg.reasoning, + reasoning_details: msg.reasoning_details, + reasoning_content: msg.reasoning_content, + codex_reasoning_items: msg.codex_reasoning_items, + }) + } + + // Update session with aggregated stats from Hermes + updateSession(localSessionId, { + source: hermesSession.source, + user_id: hermesSession.user_id, + started_at: hermesSession.started_at, + ended_at: hermesSession.ended_at, + end_reason: hermesSession.end_reason, + message_count: detail.messages.length, + tool_call_count: hermesSession.tool_call_count, + input_tokens: hermesSession.input_tokens, + output_tokens: hermesSession.output_tokens, + cache_read_tokens: hermesSession.cache_read_tokens, + cache_write_tokens: hermesSession.cache_write_tokens, + reasoning_tokens: hermesSession.reasoning_tokens, + billing_provider: hermesSession.billing_provider, + estimated_cost_usd: hermesSession.estimated_cost_usd, + actual_cost_usd: hermesSession.actual_cost_usd, + cost_status: hermesSession.cost_status, + last_active: hermesSession.last_active, + preview: hermesSession.preview, }) - } - // Update session with aggregated stats from Hermes - updateSession(newSessionId, { - started_at: hermesSession.started_at, - ended_at: hermesSession.ended_at, - end_reason: hermesSession.end_reason, - input_tokens: hermesSession.input_tokens, - output_tokens: hermesSession.output_tokens, - cache_read_tokens: hermesSession.cache_read_tokens, - cache_write_tokens: hermesSession.cache_write_tokens, - reasoning_tokens: hermesSession.reasoning_tokens, - estimated_cost_usd: hermesSession.estimated_cost_usd, - last_active: hermesSession.last_active, - preview: hermesSession.preview, - }) - - result.synced++ - logger.info(`[session-sync] synced Hermes session ${hermesSession.id} -> ${newSessionId} (${detail.messages.length} messages, thread_session_count=${detail.thread_session_count})`) - } catch (err: any) { - result.errors.push(`session ${hermesSession.id}: ${err.message}`) - logger.warn(err, `[session-sync] failed to sync session ${hermesSession.id}`) + result.synced++ + logger.info(`[session-sync] synced Hermes session ${hermesSession.id} (${source}) -> ${localSessionId} (${detail.messages.length} messages, thread_session_count=${detail.thread_session_count})`) + } catch (err: any) { + result.errors.push(`session ${hermesSession.id}: ${err.message}`) + logger.warn(err, `[session-sync] failed to sync session ${hermesSession.id}`) + } + } + } catch (err: any) { + if (!err.message.includes('state.db not found')) { + result.errors.push(`${source}: ${err.message}`) + logger.warn(err, `[session-sync] failed to open state.db for profile '${profile}', source '${source}'`) } - } - } catch (err: any) { - if (!err.message.includes('state.db not found')) { - result.errors.push(err.message) - logger.warn(err, `[session-sync] failed to open state.db for profile '${profile}'`) } } @@ -144,11 +166,9 @@ async function syncProfileSessions(profile: string): Promise<{ } /** - * Main entry point: sync all profiles on startup - * Only runs if local DB is empty (first startup or after DB reset) + * Main entry point: incrementally sync missing Hermes web UI/API sessions. */ export async function syncAllHermesSessionsOnStartup(): Promise { - // Check if local DB has any sessions - only sync if completely empty const db = getDb() if (!db) { logger.info('[session-sync] SQLite not available, skipping Hermes sync') @@ -156,24 +176,19 @@ export async function syncAllHermesSessionsOnStartup(): Promise { } const countResult = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number } | undefined - const hasExistingSessions = countResult && countResult.count > 0 - - if (hasExistingSessions) { - logger.info('[session-sync] local DB has %d sessions, skipping Hermes sync', countResult!.count) - return - } - - logger.info('[session-sync] local DB is empty, starting Hermes session sync...') + logger.info('[session-sync] local DB has %d sessions, starting incremental Hermes session sync...', countResult?.count ?? 0) const profiles = getAllProfiles() logger.info(`[session-sync] found ${profiles.length} profiles: ${profiles.join(', ')}`) let totalSynced = 0 + let totalSkipped = 0 let totalErrors = 0 for (const profile of profiles) { const result = await syncProfileSessions(profile) totalSynced += result.synced + totalSkipped += result.skipped totalErrors += result.errors.length if (result.errors.length > 0) { @@ -187,5 +202,5 @@ export async function syncAllHermesSessionsOnStartup(): Promise { } } - logger.info(`[session-sync] sync complete: synced=${totalSynced}, errors=${totalErrors}`) + logger.info(`[session-sync] sync complete: synced=${totalSynced}, skipped=${totalSkipped}, errors=${totalErrors}`) } diff --git a/tests/server/session-sync.test.ts b/tests/server/session-sync.test.ts index ade49fa6..8a16f447 100644 --- a/tests/server/session-sync.test.ts +++ b/tests/server/session-sync.test.ts @@ -1,73 +1,194 @@ /** * Tests for session-sync service */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { getDb, ensureTable } from '../../packages/server/src/db/index' -import { syncAllHermesSessionsOnStartup } from '../../packages/server/src/services/hermes/session-sync' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetDb, + mockGetSession, + mockCreateSession, + mockAddMessage, + mockUpdateSession, + mockListHermesSessionSummaries, + mockGetSessionDetailFromDbWithProfile, + mockLogger, +} = vi.hoisted(() => ({ + mockGetDb: vi.fn(), + mockGetSession: vi.fn(), + mockCreateSession: vi.fn(), + mockAddMessage: vi.fn(), + mockUpdateSession: vi.fn(), + mockListHermesSessionSummaries: vi.fn(), + mockGetSessionDetailFromDbWithProfile: vi.fn(), + mockLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +vi.mock('../../packages/server/src/db/index', () => ({ + getDb: mockGetDb, +})) + +vi.mock('../../packages/server/src/db/hermes/session-store', () => ({ + getSession: mockGetSession, + createSession: mockCreateSession, + addMessage: mockAddMessage, + updateSession: mockUpdateSession, +})) + +vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({ + listSessionSummaries: mockListHermesSessionSummaries, + getSessionDetailFromDbWithProfile: mockGetSessionDetailFromDbWithProfile, +})) + +vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({ + getProfileDir: (profile: string) => profile === 'default' ? '/fake/home/.hermes' : `/fake/home/.hermes/profiles/${profile}`, +})) + +vi.mock('../../packages/server/src/services/logger', () => ({ + logger: mockLogger, +})) + +vi.mock('os', async () => { + const actual = await vi.importActual('os') + return { ...actual, homedir: () => '/fake/home' } +}) + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs') + return { ...actual, existsSync: () => false, readdirSync: () => [] } +}) + +const webuiSummary = { + id: 'webui-session-1', + source: 'webui', + user_id: null, + model: 'gpt-5.5', + title: 'Work laptop chat', + started_at: 100, + ended_at: null, + end_reason: null, + message_count: 2, + tool_call_count: 0, + input_tokens: 10, + output_tokens: 20, + 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: 'hello from work laptop', + last_active: 110, +} + +const webuiDetail = { + ...webuiSummary, + thread_session_count: 1, + messages: [ + { + id: 1, + session_id: 'webui-session-1', + role: 'user', + content: 'hello from work laptop', + tool_call_id: null, + tool_calls: null, + tool_name: null, + timestamp: 100, + token_count: null, + finish_reason: null, + reasoning: null, + }, + { + id: 2, + session_id: 'webui-session-1', + role: 'assistant', + content: 'hello from hermes', + tool_call_id: null, + tool_calls: null, + tool_name: null, + timestamp: 110, + token_count: null, + finish_reason: 'stop', + reasoning: null, + }, + ], +} + +function makeDb(existingSessionCount: number, legacyMatch = false) { + return { + prepare: vi.fn((sql: string) => ({ + get: vi.fn(() => { + if (sql.includes('COUNT(*)')) return { count: existingSessionCount } + if (sql.includes('SELECT id FROM sessions')) return legacyMatch ? { id: 'legacy-random-id' } : undefined + return undefined + }), + })), + } +} describe('session-sync', () => { beforeEach(() => { - // Reset database before each test - const db = getDb() - if (db) { - db.exec('DELETE FROM sessions') - db.exec('DELETE FROM messages') - } + vi.clearAllMocks() + mockGetDb.mockReturnValue(makeDb(1)) + mockGetSession.mockReturnValue(null) + mockCreateSession.mockImplementation((data: any) => ({ + id: data.id, + profile: data.profile || 'default', + source: 'api_server', + model: data.model || '', + title: data.title || null, + })) + mockListHermesSessionSummaries.mockImplementation(async (source?: string) => { + if (source === 'webui') return [webuiSummary] + return [] + }) + mockGetSessionDetailFromDbWithProfile.mockResolvedValue(webuiDetail) }) - afterEach(() => { - // Cleanup after each test - const db = getDb() - if (db) { - db.exec('DELETE FROM sessions') - db.exec('DELETE FROM messages') - } - }) + it('imports missing webui sessions even when the local DB already has sessions', async () => { + const { syncAllHermesSessionsOnStartup } = await import('../../packages/server/src/services/hermes/session-sync') - it('should skip sync when local DB is not empty', () => { - const db = getDb() - expect(db).not.toBeNull() + await syncAllHermesSessionsOnStartup() - // Insert a test session - db!.prepare(` - INSERT INTO sessions (id, profile, source, model, title, started_at, last_active) - VALUES ('test-session-1', 'default', 'api_server', 'gpt-4', 'Test Session', ${Date.now()}, ${Date.now()}) - `).run() + expect(mockListHermesSessionSummaries).toHaveBeenCalledWith('webui', 10000, 'default') + expect(mockCreateSession).toHaveBeenCalledWith({ + id: 'webui-session-1', + profile: 'default', + model: 'gpt-5.5', + title: 'Work laptop chat', + }) + expect(mockUpdateSession).toHaveBeenCalledWith('webui-session-1', expect.objectContaining({ + source: 'webui', + started_at: 100, + last_active: 110, + })) + expect(mockAddMessage).toHaveBeenCalledTimes(2) + }) - // Check that session exists - const countResult = db!.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number } - expect(countResult.count).toBe(1) + it('skips a Hermes session that has already been imported by id', async () => { + mockGetSession.mockReturnValue({ id: 'webui-session-1' }) + const { syncAllHermesSessionsOnStartup } = await import('../../packages/server/src/services/hermes/session-sync') - // Run sync - should skip because DB is not empty - syncAllHermesSessionsOnStartup() + await syncAllHermesSessionsOnStartup() - // Verify session still exists (no changes) - const countAfter = db!.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number } - expect(countAfter.count).toBe(1) + expect(mockCreateSession).not.toHaveBeenCalled() + expect(mockAddMessage).not.toHaveBeenCalled() }) - it('should attempt sync when local DB is empty', () => { - const db = getDb() - expect(db).not.toBeNull() + it('skips a Hermes session that matches an older random-id import', async () => { + mockGetDb.mockReturnValue(makeDb(1, true)) + const { syncAllHermesSessionsOnStartup } = await import('../../packages/server/src/services/hermes/session-sync') - // Verify DB is empty - const countBefore = db!.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number } - expect(countBefore.count).toBe(0) + await syncAllHermesSessionsOnStartup() - // Run sync - should attempt to sync from Hermes - syncAllHermesSessionsOnStartup() - - // Note: Whether sessions are actually imported depends on whether - // Hermes state.db exists and has api_server sessions - // This test mainly verifies the function doesn't crash when DB is empty - expect(true).toBe(true) + expect(mockCreateSession).not.toHaveBeenCalled() + expect(mockAddMessage).not.toHaveBeenCalled() }) - it('should handle case when SQLite is not available', () => { - // This test verifies the function handles the case when getDb() returns null - // Since we can't easily mock getDb(), we just verify it doesn't crash - expect(() => { - syncAllHermesSessionsOnStartup() - }).not.toThrow() + it('does not throw when SQLite is unavailable', async () => { + mockGetDb.mockReturnValue(null) + const { syncAllHermesSessionsOnStartup } = await import('../../packages/server/src/services/hermes/session-sync') + + await expect(syncAllHermesSessionsOnStartup()).resolves.toBeUndefined() }) })